Compare commits
8 Commits
f4f17c10d8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ee0e546e3 | |||
| 6850ceff56 | |||
| 09bf329544 | |||
| 8f13604e0e | |||
| 00a3904eba | |||
| 31cc1e02ce | |||
| 05bf267d09 | |||
| 8a86334d05 |
@@ -13,6 +13,8 @@ out/
|
|||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.playwright-mcp/
|
||||||
|
gitea-login.png
|
||||||
.env.*.local
|
.env.*.local
|
||||||
backend/.env
|
backend/.env
|
||||||
frontend/.env.local
|
frontend/.env.local
|
||||||
@@ -66,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,63 @@
|
|||||||
|
-- 联盟返佣系统:规范化数据模型
|
||||||
|
-- 替换原先手写的 affiliate_stats / affiliate_clicks 裸表,改用 ORM 管理
|
||||||
|
-- 注意:Prisma 字段名即为列名(camelCase),仅通过 @@map 映射表名为 snake_case
|
||||||
|
|
||||||
|
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,
|
||||||
|
`commissionRate` VARCHAR(191),
|
||||||
|
`cookieDays` INT,
|
||||||
|
`homepageUrl` VARCHAR(191),
|
||||||
|
`logo` VARCHAR(191),
|
||||||
|
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`sortOrder` INT NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` 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,
|
||||||
|
`programId` INT NOT NULL,
|
||||||
|
`title` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT,
|
||||||
|
`url` TEXT NOT NULL,
|
||||||
|
`thumbnail` VARCHAR(191),
|
||||||
|
`toolId` INT,
|
||||||
|
`skillId` VARCHAR(191),
|
||||||
|
`tags` TEXT,
|
||||||
|
`estimatedCommission` DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
`sortOrder` INT NOT NULL DEFAULT 0,
|
||||||
|
`isFeatured` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `affiliate_links_programId_idx` (`programId`),
|
||||||
|
INDEX `affiliate_links_toolId_idx` (`toolId`),
|
||||||
|
INDEX `affiliate_links_skillId_idx` (`skillId`),
|
||||||
|
CONSTRAINT `affiliate_links_programId_fkey` FOREIGN KEY (`programId`) REFERENCES `affiliate_programs` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `affiliate_links_toolId_fkey` FOREIGN KEY (`toolId`) REFERENCES `tools` (`id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `affiliate_links_skillId_fkey` FOREIGN KEY (`skillId`) 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,
|
||||||
|
`linkId` INT NOT NULL,
|
||||||
|
`userId` INT,
|
||||||
|
`ip` VARCHAR(191),
|
||||||
|
`userAgent` TEXT,
|
||||||
|
`ref` VARCHAR(191),
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `affiliate_clicks_linkId_idx` (`linkId`),
|
||||||
|
CONSTRAINT `affiliate_clicks_linkId_fkey` FOREIGN KEY (`linkId`) 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`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 支持模式:用户自愿打赏(个人收款码捐赠)留言记录
|
||||||
|
-- 平台不代收任何费用,此处仅用于展示「感谢墙」,不记录支付流水
|
||||||
|
-- 注意:Prisma 字段名即为列名(camelCase),仅通过 @@map 映射表名为 snake_case
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `donations` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(191),
|
||||||
|
`message` VARCHAR(280),
|
||||||
|
`channel` VARCHAR(191) NOT NULL DEFAULT 'ALIPAY',
|
||||||
|
`amount` DOUBLE,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `donations_createdAt_idx` (`createdAt`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
+159
-90
@@ -7,34 +7,8 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserStatus {
|
|
||||||
ACTIVE
|
|
||||||
INACTIVE
|
|
||||||
BANNED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ContentStatus {
|
|
||||||
DRAFT
|
|
||||||
PUBLISHED
|
|
||||||
ARCHIVED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OrderStatus {
|
|
||||||
PENDING
|
|
||||||
PAID
|
|
||||||
CANCELLED
|
|
||||||
REFUNDED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MemberPlan {
|
|
||||||
FREE
|
|
||||||
MONTHLY
|
|
||||||
YEARLY
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String? @unique
|
|
||||||
phone String? @unique
|
phone String? @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
@@ -45,7 +19,6 @@ model User {
|
|||||||
memberPlan MemberPlan @default(FREE)
|
memberPlan MemberPlan @default(FREE)
|
||||||
memberExpire DateTime?
|
memberExpire DateTime?
|
||||||
sandboxDaily Int @default(5)
|
sandboxDaily Int @default(5)
|
||||||
sandboxExtra Int @default(0)
|
|
||||||
followerCount Int @default(0)
|
followerCount Int @default(0)
|
||||||
followingCount Int @default(0)
|
followingCount Int @default(0)
|
||||||
postCount Int @default(0)
|
postCount Int @default(0)
|
||||||
@@ -53,23 +26,24 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
|
username String? @unique
|
||||||
|
sandboxExtra Int @default(0)
|
||||||
|
circleMemberships CircleMember[]
|
||||||
|
createdCircles Circle[] @relation("CircleCreator")
|
||||||
|
comments Comment[]
|
||||||
|
following Follow[] @relation("Follower")
|
||||||
|
followers Follow[] @relation("Following")
|
||||||
learnRecords LearnRecord[]
|
learnRecords LearnRecord[]
|
||||||
|
notifications Notification[]
|
||||||
|
orders Order[]
|
||||||
|
organizationMemberships OrganizationMember[]
|
||||||
|
postLikes PostLike[]
|
||||||
|
posts Post[]
|
||||||
|
practiceSubmissions PracticeSubmission[]
|
||||||
promptFavorites PromptFavorite[]
|
promptFavorites PromptFavorite[]
|
||||||
prompts Prompt[]
|
prompts Prompt[]
|
||||||
sandboxSessions SandboxSession[]
|
sandboxSessions SandboxSession[]
|
||||||
orders Order[]
|
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
posts Post[]
|
|
||||||
comments Comment[]
|
|
||||||
postLikes PostLike[]
|
|
||||||
followers Follow[] @relation("Following")
|
|
||||||
following Follow[] @relation("Follower")
|
|
||||||
createdCircles Circle[] @relation("CircleCreator")
|
|
||||||
circleMemberships CircleMember[]
|
|
||||||
organizationMemberships OrganizationMember[]
|
|
||||||
notifications Notification[]
|
|
||||||
practiceSubmissions PracticeSubmission[]
|
|
||||||
userSkills UserSkill[]
|
userSkills UserSkill[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -84,15 +58,14 @@ model Category {
|
|||||||
parentId Int?
|
parentId Int?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
|
||||||
children Category[] @relation("CategoryTree")
|
children Category[] @relation("CategoryTree")
|
||||||
|
contents Content[]
|
||||||
courses Course[]
|
courses Course[]
|
||||||
prompts Prompt[]
|
prompts Prompt[]
|
||||||
contents Content[]
|
|
||||||
tools Tool[]
|
tools Tool[]
|
||||||
|
|
||||||
|
@@index([parentId], map: "categories_parentId_fkey")
|
||||||
@@map("categories")
|
@@map("categories")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +82,12 @@ model Course {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
|
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
|
||||||
chapters Chapter[]
|
chapters Chapter[]
|
||||||
progress LearnRecord[]
|
|
||||||
assignments CourseAssignment[]
|
assignments CourseAssignment[]
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
progress LearnRecord[]
|
||||||
|
|
||||||
|
@@index([categoryId], map: "courses_categoryId_fkey")
|
||||||
@@map("courses")
|
@@map("courses")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +97,10 @@ model Chapter {
|
|||||||
title String
|
title String
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
lessons Lesson[]
|
lessons Lesson[]
|
||||||
|
|
||||||
|
@@index([courseId], map: "chapters_courseId_fkey")
|
||||||
@@map("chapters")
|
@@map("chapters")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +115,10 @@ model Lesson {
|
|||||||
status ContentStatus @default(DRAFT)
|
status ContentStatus @default(DRAFT)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
|
|
||||||
progress LearnRecord[]
|
progress LearnRecord[]
|
||||||
|
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([chapterId], map: "lessons_chapterId_fkey")
|
||||||
@@map("lessons")
|
@@map("lessons")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +131,13 @@ model LearnRecord {
|
|||||||
progress Float @default(0)
|
progress Float @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, lessonId])
|
@@unique([userId, lessonId])
|
||||||
|
@@index([courseId], map: "learn_records_courseId_fkey")
|
||||||
|
@@index([lessonId], map: "learn_records_lessonId_fkey")
|
||||||
@@map("learn_records")
|
@@map("learn_records")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,11 +157,12 @@ model Prompt {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
|
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
|
||||||
author User? @relation(fields: [authorId], references: [id])
|
|
||||||
favorites PromptFavorite[]
|
favorites PromptFavorite[]
|
||||||
|
author User? @relation(fields: [authorId], references: [id])
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
|
@@index([authorId], map: "prompts_authorId_fkey")
|
||||||
|
@@index([categoryId], map: "prompts_categoryId_fkey")
|
||||||
@@map("prompts")
|
@@map("prompts")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,21 +171,24 @@ model PromptFavorite {
|
|||||||
userId Int
|
userId Int
|
||||||
promptId Int
|
promptId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, promptId])
|
@@unique([userId, promptId])
|
||||||
|
@@index([promptId], map: "prompt_favorites_promptId_fkey")
|
||||||
@@map("prompt_favorites")
|
@@map("prompt_favorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tool {
|
model Tool {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
slug String? @unique(map: "slug") @db.VarChar(200)
|
||||||
name String
|
name String
|
||||||
|
metaTitle String? @db.VarChar(200)
|
||||||
|
metaDesc String? @db.VarChar(300)
|
||||||
description String?
|
description String?
|
||||||
|
content String? @db.LongText
|
||||||
url String
|
url String
|
||||||
icon String?
|
icon String?
|
||||||
affiliateLink String?
|
|
||||||
categoryId Int?
|
categoryId Int?
|
||||||
tags String?
|
tags String?
|
||||||
isFeatured Boolean @default(false)
|
isFeatured Boolean @default(false)
|
||||||
@@ -219,9 +197,10 @@ model Tool {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
|
affiliateLink String?
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
|
@@index([categoryId], map: "tools_categoryId_fkey")
|
||||||
@@map("tools")
|
@@map("tools")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,9 +221,9 @@ model Content {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
|
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
|
@@index([categoryId], map: "contents_categoryId_fkey")
|
||||||
@@map("contents")
|
@@map("contents")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,12 +259,11 @@ model Skill {
|
|||||||
tasks String @db.Text
|
tasks String @db.Text
|
||||||
tags String
|
tags String
|
||||||
prerequisiteIds String?
|
prerequisiteIds String?
|
||||||
price Float? @default(0)
|
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
price Float? @default(0)
|
||||||
purchasedBy UserSkill[]
|
purchasedBy UserSkill[]
|
||||||
|
|
||||||
@@map("skills")
|
@@map("skills")
|
||||||
@@ -302,7 +280,6 @@ model SandboxSession {
|
|||||||
tokens Int @default(0)
|
tokens Int @default(0)
|
||||||
duration Int @default(0)
|
duration Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, conversationId])
|
@@unique([userId, conversationId])
|
||||||
@@ -318,13 +295,12 @@ model Order {
|
|||||||
planType String
|
planType String
|
||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
payChannel String?
|
payChannel String?
|
||||||
transactionId String?
|
|
||||||
gatewayOrderId String?
|
|
||||||
metadata String?
|
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
transactionId String?
|
||||||
|
gatewayOrderId String?
|
||||||
|
metadata String?
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userSkills UserSkill[]
|
userSkills UserSkill[]
|
||||||
|
|
||||||
@@ -339,12 +315,13 @@ model UserSkill {
|
|||||||
skillId String
|
skillId String
|
||||||
orderId Int?
|
orderId Int?
|
||||||
purchasedAt DateTime @default(now())
|
purchasedAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
skill Skill @relation(fields: [skillId], references: [id])
|
|
||||||
order Order? @relation(fields: [orderId], references: [id])
|
order Order? @relation(fields: [orderId], references: [id])
|
||||||
|
skill Skill @relation(fields: [skillId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, skillId])
|
@@unique([userId, skillId])
|
||||||
|
@@index([orderId], map: "user_skills_orderId_fkey")
|
||||||
|
@@index([skillId], map: "user_skills_skillId_fkey")
|
||||||
@@map("user_skills")
|
@@map("user_skills")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +334,6 @@ model Subscription {
|
|||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@ -368,7 +344,7 @@ model AdminRole {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
description String?
|
description String?
|
||||||
permissions Json @default("[]")
|
permissions Json
|
||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -383,12 +359,13 @@ model AdminUser {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
nickname String?
|
nickname String?
|
||||||
roleId String?
|
roleId String?
|
||||||
role AdminRole? @relation(fields: [roleId], references: [id])
|
|
||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
role AdminRole? @relation(fields: [roleId], references: [id])
|
||||||
|
|
||||||
|
@@index([roleId], map: "admin_users_roleId_fkey")
|
||||||
@@map("admin_users")
|
@@map("admin_users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,11 +394,10 @@ model Post {
|
|||||||
commentCount Int @default(0)
|
commentCount Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
likes PostLike[]
|
likes PostLike[]
|
||||||
circle Circle? @relation(fields: [circleId], references: [id])
|
circle Circle? @relation(fields: [circleId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@ -439,14 +415,15 @@ model Comment {
|
|||||||
reviewNote String?
|
reviewNote String?
|
||||||
reviewedAt DateTime?
|
reviewedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
parent Comment? @relation("CommentReply", fields: [parentId], references: [id])
|
parent Comment? @relation("CommentReply", fields: [parentId], references: [id])
|
||||||
replies Comment[] @relation("CommentReply")
|
replies Comment[] @relation("CommentReply")
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([postId])
|
@@index([postId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([parentId], map: "comments_parentId_fkey")
|
||||||
|
@@index([userId], map: "comments_userId_fkey")
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,11 +432,11 @@ model PostLike {
|
|||||||
postId Int
|
postId Int
|
||||||
userId Int
|
userId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([postId, userId])
|
@@unique([postId, userId])
|
||||||
|
@@index([userId], map: "post_likes_userId_fkey")
|
||||||
@@map("post_likes")
|
@@map("post_likes")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,11 +449,11 @@ model Circle {
|
|||||||
isPublic Boolean @default(true)
|
isPublic Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
creator User @relation("CircleCreator", fields: [creatorId], references: [id])
|
|
||||||
members CircleMember[]
|
members CircleMember[]
|
||||||
|
creator User @relation("CircleCreator", fields: [creatorId], references: [id])
|
||||||
posts Post[]
|
posts Post[]
|
||||||
|
|
||||||
|
@@index([creatorId], map: "circles_creatorId_fkey")
|
||||||
@@map("circles")
|
@@map("circles")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,11 +463,11 @@ model CircleMember {
|
|||||||
userId Int
|
userId Int
|
||||||
role String @default("member")
|
role String @default("member")
|
||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
|
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([circleId, userId])
|
@@unique([circleId, userId])
|
||||||
|
@@index([userId], map: "circle_members_userId_fkey")
|
||||||
@@map("circle_members")
|
@@map("circle_members")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,11 +476,11 @@ model Follow {
|
|||||||
followerId Int
|
followerId Int
|
||||||
followingId Int
|
followingId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
|
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
|
||||||
following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade)
|
following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([followerId, followingId])
|
@@unique([followerId, followingId])
|
||||||
|
@@index([followingId], map: "follows_followingId_fkey")
|
||||||
@@map("follows")
|
@@map("follows")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,9 +495,8 @@ model Organization {
|
|||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
members OrganizationMember[]
|
|
||||||
assignments CourseAssignment[]
|
assignments CourseAssignment[]
|
||||||
|
members OrganizationMember[]
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
@@ -532,11 +508,11 @@ model OrganizationMember {
|
|||||||
role String @default("MEMBER")
|
role String @default("MEMBER")
|
||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([organizationId, userId])
|
@@unique([organizationId, userId])
|
||||||
|
@@index([userId], map: "organization_members_userId_fkey")
|
||||||
@@map("organization_members")
|
@@map("organization_members")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,24 +523,24 @@ model CourseAssignment {
|
|||||||
assignedBy Int
|
assignedBy Int
|
||||||
deadline DateTime?
|
deadline DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([courseId], map: "course_assignments_courseId_fkey")
|
||||||
|
@@index([organizationId], map: "course_assignments_organizationId_fkey")
|
||||||
@@map("course_assignments")
|
@@map("course_assignments")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
type String // 'like', 'comment', 'follow', 'system'
|
type String
|
||||||
title String
|
title String
|
||||||
content String?
|
content String?
|
||||||
link String?
|
link String?
|
||||||
relatedId Int?
|
relatedId Int?
|
||||||
isRead Boolean @default(false)
|
isRead Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId, isRead])
|
@@index([userId, isRead])
|
||||||
@@ -588,7 +564,6 @@ model PracticeQuestion {
|
|||||||
attemptCount Int @default(0)
|
attemptCount Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
submissions PracticeSubmission[]
|
submissions PracticeSubmission[]
|
||||||
|
|
||||||
@@map("practice_questions")
|
@@map("practice_questions")
|
||||||
@@ -607,7 +582,6 @@ model PracticeSubmission {
|
|||||||
status String @default("SUBMITTED")
|
status String @default("SUBMITTED")
|
||||||
submittedAt DateTime @default(now())
|
submittedAt DateTime @default(now())
|
||||||
scoredAt DateTime?
|
scoredAt DateTime?
|
||||||
|
|
||||||
question PracticeQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
question PracticeQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -661,3 +635,98 @@ model SystemConfig {
|
|||||||
|
|
||||||
@@map("system_configs")
|
@@map("system_configs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserStatus {
|
||||||
|
ACTIVE
|
||||||
|
INACTIVE
|
||||||
|
BANNED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContentStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
PENDING
|
||||||
|
PAID
|
||||||
|
CANCELLED
|
||||||
|
REFUNDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemberPlan {
|
||||||
|
FREE
|
||||||
|
MONTHLY
|
||||||
|
YEARLY
|
||||||
|
}
|
||||||
|
|
||||||
|
model AffiliateProgram {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
provider String?
|
||||||
|
description String?
|
||||||
|
commissionRate String?
|
||||||
|
cookieDays Int?
|
||||||
|
homepageUrl String?
|
||||||
|
logo String?
|
||||||
|
active Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
links AffiliateLink[]
|
||||||
|
|
||||||
|
@@map("affiliate_programs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AffiliateLink {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
programId Int
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
url String
|
||||||
|
thumbnail String?
|
||||||
|
toolId Int?
|
||||||
|
skillId String?
|
||||||
|
tags String?
|
||||||
|
estimatedCommission Float @default(0)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isFeatured Boolean @default(false)
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
clicks AffiliateClick[]
|
||||||
|
program AffiliateProgram @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([programId])
|
||||||
|
@@index([toolId])
|
||||||
|
@@index([skillId])
|
||||||
|
@@map("affiliate_links")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AffiliateClick {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
linkId Int
|
||||||
|
userId Int?
|
||||||
|
ip String?
|
||||||
|
userAgent String?
|
||||||
|
ref String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
link AffiliateLink @relation(fields: [linkId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([linkId])
|
||||||
|
@@map("affiliate_clicks")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Donation {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String?
|
||||||
|
message String? @db.VarChar(280)
|
||||||
|
channel String @default("ALIPAY")
|
||||||
|
amount Float?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("donations")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
+2
@@ -25,6 +25,7 @@ import { LearningModule } from './modules/learning/learning.module';
|
|||||||
import { SkillsModule } from './modules/skills/skills.module';
|
import { SkillsModule } from './modules/skills/skills.module';
|
||||||
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
|
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
|
||||||
import { PracticesModule } from './modules/practices/practices.module';
|
import { PracticesModule } from './modules/practices/practices.module';
|
||||||
|
import { DonationsModule } from './modules/donations/donations.module';
|
||||||
import { RedisThrottlerStorage } from './common/redis-throttler-storage';
|
import { RedisThrottlerStorage } from './common/redis-throttler-storage';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -45,6 +46,7 @@ import { RedisThrottlerStorage } from './common/redis-throttler-storage';
|
|||||||
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
|
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
|
||||||
AiAssistantModule,
|
AiAssistantModule,
|
||||||
PracticesModule,
|
PracticesModule,
|
||||||
|
DonationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export class AIGatewayService {
|
|||||||
if (!apiUrl.endsWith('/chat/completions')) {
|
if (!apiUrl.endsWith('/chat/completions')) {
|
||||||
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
|
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
|
||||||
}
|
}
|
||||||
const models = ['deepseek-v4-flash', 'sensenova-6.7-flash-lite'];
|
// 商汤科技仅注册 sensenova 自有模型,避免覆盖硅基流动的 deepseek 注册
|
||||||
for (const modelName of models) {
|
const modelName = 'sensenova-6.7-flash-lite';
|
||||||
this.providers.set(modelName, new OpenAICompatibleProvider(
|
this.providers.set(modelName, new OpenAICompatibleProvider(
|
||||||
process.env.SENSENOVA_API_KEY!,
|
process.env.SENSENOVA_API_KEY!,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
@@ -78,8 +78,7 @@ export class AIGatewayService {
|
|||||||
'商汤科技',
|
'商汤科技',
|
||||||
));
|
));
|
||||||
this.markAvailable(modelName);
|
this.markAvailable(modelName);
|
||||||
}
|
this.logger.log(`商汤科技已注册: ${modelName}`);
|
||||||
this.logger.log(`商汤科技已注册: ${models.join(', ')}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, HttpCode } from '@nestjs/common';
|
||||||
|
import { DonationsService } from './donations.service';
|
||||||
|
import { CreateDonationDto } from './dto/create-donation.dto';
|
||||||
|
|
||||||
|
@Controller('donations')
|
||||||
|
export class DonationsController {
|
||||||
|
constructor(private readonly donationsService: DonationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getList(@Query('limit') limit?: string) {
|
||||||
|
const parsed = limit ? Math.min(parseInt(limit, 10) || 20, 50) : 20;
|
||||||
|
return this.donationsService.getList(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(200)
|
||||||
|
create(@Body() dto: CreateDonationDto) {
|
||||||
|
return this.donationsService.create(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DonationsService } from './donations.service';
|
||||||
|
import { DonationsController } from './donations.controller';
|
||||||
|
import { PrismaModule } from '../../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [DonationsController],
|
||||||
|
providers: [DonationsService],
|
||||||
|
exports: [DonationsService],
|
||||||
|
})
|
||||||
|
export class DonationsModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DonationsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getList(limit = 20) {
|
||||||
|
const donations = await this.prisma.donation.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
message: true,
|
||||||
|
channel: true,
|
||||||
|
amount: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
donations: donations.map((d) => ({
|
||||||
|
...d,
|
||||||
|
amount: d.amount != null ? Number(d.amount) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: {
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
channel?: 'ALIPAY' | 'WECHAT';
|
||||||
|
amount?: number;
|
||||||
|
}) {
|
||||||
|
const donation = await this.prisma.donation.create({
|
||||||
|
data: {
|
||||||
|
name: data.name ?? null,
|
||||||
|
message: data.message ?? null,
|
||||||
|
channel: data.channel ?? 'ALIPAY',
|
||||||
|
amount: data.amount != null ? data.amount : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
donation: {
|
||||||
|
...donation,
|
||||||
|
amount: donation.amount != null ? Number(donation.amount) : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { IsOptional, IsString, IsIn, IsNumber, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateDonationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(280)
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ALIPAY', 'WECHAT'])
|
||||||
|
channel?: 'ALIPAY' | 'WECHAT';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||||
|
@Min(0)
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export class SandboxController {
|
|||||||
}
|
}
|
||||||
res.end();
|
res.end();
|
||||||
} else {
|
} else {
|
||||||
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body, body.images);
|
const result = await this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body, body.images);
|
||||||
|
res.json(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,12 +94,22 @@ cd backend && node prisma/update-skill.cjs # 如果 system prompt 需要更新
|
|||||||
| **models.ts 清理** | 移除 foreign 模型,仅保留 deepseek-v4-flash 和 sensenova-6.7-flash-lite,DEFAULT_MODEL 改为 domestic | ✅ |
|
| **models.ts 清理** | 移除 foreign 模型,仅保留 deepseek-v4-flash 和 sensenova-6.7-flash-lite,DEFAULT_MODEL 改为 domestic | ✅ |
|
||||||
| **返佣注册指南** | 新建 `docs/affiliate-registration.md`,收录 7 个平台的注册入口、佣金比例、申请流程 | ✅ |
|
| **返佣注册指南** | 新建 `docs/affiliate-registration.md`,收录 7 个平台的注册入口、佣金比例、申请流程 | ✅ |
|
||||||
|
|
||||||
|
### P8.4 — @Res() 非流式响应 Bug 修复 + 双模型验证 + 生产部署(2026-06-24)
|
||||||
|
|
||||||
|
| 子项 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Bug 诊断** | `sandbox.controller.ts` 注入 `@Res() res: Response` 后 NestJS 进入 Express native 模式,非流式 `return` 值被静默丢弃,curl 收到 0 bytes | ✅ |
|
||||||
|
| **Bug 修复** | 非流式分支改为 `const result = await ...; res.json(result)`,流式分支不变 | ✅ |
|
||||||
|
| **provider 覆盖修复** | 商汤科技此前同时注册 `deepseek-v4-flash` + `sensenova`,覆盖了硅基流动的 deepseek 注册。修复后商汤仅注册 `sensenova-6.7-flash-lite` | ✅ |
|
||||||
|
| **双模型功能验证** | `deepseek-v4-flash`(硅基流动) HTTP 201 ~3-18s 正常回复;`sensenova-6.7-flash-lite`(商汤科技) HTTP 201 ~2s 正常回复 | ✅ |
|
||||||
|
| **生产部署** | 前端 108 页面构建推送主站+镜像站,后端 build + PM2 restart,Nginx reload | ✅ |
|
||||||
|
| **线上验证** | 编译产物仅含 `deepseek-v4-flash` 和 `sensenova-6.7-flash-lite`,无任何国外模型引用 | ✅ |
|
||||||
|
|
||||||
### 待办/下一步
|
### 待办/下一步
|
||||||
1. **注册联盟账号**:按 affiliate-registration.md 优先级注册阿里云云大使、百度智能云推广大使、Canvassador、腾讯云推广大使
|
1. **注册联盟账号**:按 affiliate-registration.md 优先级注册阿里云云大使、百度智能云推广大使、Canvassador、腾讯云推广大使
|
||||||
2. **替换占位链接**:拿到真实联盟 ID 后替换 seed-tools-affiliate.ts 中的 `yuzhiran` 占位
|
2. **替换占位链接**:拿到真实联盟 ID 后替换 seed-tools-affiliate.ts 中的 `yuzhiran` 占位
|
||||||
3. **生产部署**:`npm run build`(前端 SSG)+ 复制到服务器 + PM2 restart 后端
|
3. **内容策略**:批量生产 AI 工具评测文章做 SEO
|
||||||
4. **内容策略**:批量生产 AI 工具评测文章做 SEO
|
4. **编程导师内容扩充**:按难度分级写更多 starter 问题
|
||||||
5. **编程导师内容扩充**:按难度分级写更多 starter 问题
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 宇之然 AI 平台 - 进度追踪
|
||||||
|
|
||||||
|
**文档版本:** v1.3.0
|
||||||
|
**最后更新:** 2026-07-11
|
||||||
|
**状态:** 🟢 联盟返佣 + 打赏(个人码捐赠)双变现模式已落地;详情页正文已服务端渲染(SEO 盲点已解决);付费订阅/付费 Skill/用量包已下线
|
||||||
|
**负责人:** Hermes Agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
|
||||||
|
- **目标:** 在「无 ICP 经营许可证」阶段,将全部经营性收费(会员订阅、付费 Skill、沙箱用量包)下架,改为合规的「打赏(个人码捐赠)+ 联盟返佣」模式
|
||||||
|
- **合规定位:** 会员付费/付费 Skill/虚拟商品属经营性收费,需 ICP 经营许可证。在取证前:以**联盟返佣**(第三方平台结算佣金,非平台经营性收款)为主力变现;以**打赏/捐赠**(个人收款码,自愿赠与,平台不提供对价)作为补充;所有内容(技能、练习、工具指南)保持免费开放
|
||||||
|
- **范围:** 打赏数据模型与 API、前端 /donate 页、会员/技能广场/用量包三处付费面改造为免费+赞助、导航与页脚入口、单元测试、种子数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
| 模块 | 内容 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 数据模型 | `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` |
|
||||||
|
|
||||||
|
### 打赏(个人码捐赠)模式 — v1.2.0
|
||||||
|
|
||||||
|
| 模块 | 内容 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 数据模型 | `Donation` | Prisma 模型(`name`/`message`/`channel`/`amount`/`createdAt`),仅用于「感谢墙」展示,不记录支付流水 |
|
||||||
|
| 迁移 | `prisma/migrations/20260711010000_add_donations` | 建 `donations` 表;部署时 `prisma migrate deploy` |
|
||||||
|
| 后端服务 | `donations.service.ts` | `getList(limit)` 最近留言、`create(dto)` 保存自愿留言 |
|
||||||
|
| 后端接口 | `donations.controller.ts` | `GET /donations`、`POST /donations`(公开,无鉴权) |
|
||||||
|
| 前端页 | `donate/page.tsx` | 个人收款码(支付宝/微信,环境变量 `NEXT_PUBLIC_DONATE_ALIPAY_QR`/`NEXT_PUBLIC_DONATE_WECHAT_QR`)+ 感谢留言表单 + 感谢墙 |
|
||||||
|
| 会员页 | `my/member/page.tsx` | 移除付费订阅与 PaymentModal,改为免费说明 + 赞助/联盟入口 |
|
||||||
|
| 技能广场 | `marketplace/page.tsx` | 所有 Skill 免费开放,下架付费购买与 PaymentModal |
|
||||||
|
| 用量包 | `practices/packages/page.tsx` | 下架付费用量包,仅展示免费配额 + 赞助入口 |
|
||||||
|
| 沙箱 | `sandbox/page.tsx` | 用量耗尽提示改为引导至 /donate 赞助 |
|
||||||
|
| 课程详情 | `courses/[id]/client.tsx` | 下架付费购买按钮与微信扫码支付弹窗,全部免费开放 |
|
||||||
|
| 首页/能力导览 | `page.tsx` / `models.*` | 文案由"购买即用"改为"免费使用" |
|
||||||
|
| 导航/页脚 | `header.tsx` / `footer.tsx` | 新增「赞助」「联盟返佣」入口 |
|
||||||
|
| i18n | `zh.ts` / `en.ts` | 新增 `donate.*`、导航 `affiliate`/`donate`、说明文案 |
|
||||||
|
|
||||||
|
> **说明:** 后端 `orders` / `payment` 模块代码保留(取证后可复用),但前端已不再调用付费购买流程。
|
||||||
|
|
||||||
|
### SEO 基础优化 — v1.2.0
|
||||||
|
|
||||||
|
| 项目 | 改动 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 结构化数据 | 详情页注入 JSON-LD | 课程/技能/文章/练习:`Article` + `BreadcrumbList`;工具:`SoftwareApplication`(含免费 Offer) |
|
||||||
|
| 每页元数据 | 详情页 `generateMetadata` | 课程/技能/文章/练习动态生成 unique title/description + canonical + OG |
|
||||||
|
| 列表页元数据 | 新增 `skills/contents/practices/prompts/models` 的 `layout.tsx` | 补齐各频道独立 title/description/canonical,消除与首页重复 |
|
||||||
|
| hreflang | 修正 `layout.tsx` | 删除不存在的 `/en` 备用语言,仅保留 `zh-CN` 自引用 |
|
||||||
|
| OG 分享图 | 新增 `app/opengraph-image.tsx` | 用 `next/og` 在构建期生成 1200x630 分享图(Latin 文案,规避 CJK 字体缺失),替换原缺失的 `/images/og-image.png` |
|
||||||
|
| 工具页 | 增强 `SoftwareApplication` JSON-LD | 补充 publisher / offers(免费) / operatingSystem |
|
||||||
|
|
||||||
|
> **已知最大盲点(已解决):** 原 `courses/[id]`、`skills/[id]`、`contents/[id]`、`practices/[id]` 正文在客户端 `useEffect` 拉取,静态 HTML 无正文。已于 v1.3.0 改为「服务端 `getX()` 取数 → 作为 `initialData` 传入客户端组件」,正文(含文章渲染)写入静态 HTML,百度等弱 JS 渲染引擎可正常收录。
|
||||||
|
|
||||||
|
### 详情页服务端渲染(解决 SEO 盲点)— v1.3.0
|
||||||
|
|
||||||
|
| 项目 | 改动 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| courses/[id] | `page.tsx` 服务端 `getCourse()` + `client.tsx` 接收 `initialCourse` | 课程正文/章节服务端取数,静态 HTML 含实质内容 |
|
||||||
|
| skills/[id] | `page.tsx` 服务端 `getSkill()` + `client.tsx` 接收 `initialSkill` | 技能描述/systemPrompt 写入静态 HTML |
|
||||||
|
| contents/[id] | `page.tsx` 服务端 `getContent()` + `client.tsx` 用 `useMemo` 同步渲染 markdown | 文章正文由 `useEffect+setState` 改为同步输出,进入静态 HTML |
|
||||||
|
| practices/[id] | `page.tsx` 服务端 `getPractice()` + `client.tsx` 接收 `initialQuestion` | 练习题干写入静态 HTML |
|
||||||
|
| generateStaticParams | 四页均改为从 API 取真实 id 列表 | 课程/技能/文章/练习全部按真实数据生成静态页(技能/练习原硬编码 id 会导致 404 错误体被当作有效数据) |
|
||||||
|
| 取数健壮性 | 四个 `getX()` 均校验 `data.id` | 后端对缺失资源返回 `HTTP 200 + {error}`(非 404),须据此判定缺失并 `return null`,避免客户端对 undefined 数组 `.map` 报错 |
|
||||||
|
| 构建验证 | `npm run build` | EXIT=0,216 个 HTML 页面成功导出,技能/课程/文章/练习静态 HTML 实测含正文与 JSON-LD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待办(下一阶段)
|
||||||
|
|
||||||
|
| 任务 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 部署配置 | 在部署环境设置支付宝/微信收款码环境变量(`NEXT_PUBLIC_DONATE_*_QR`) |
|
||||||
|
| 管理后台 UI | AffiliateProgram/Link 的列表与编辑页(T-009~T-013) |
|
||||||
|
| 首页/工具页嵌入 | 在首页与工具详情侧边栏插入「云产品推荐」区块(T-016/T-017) |
|
||||||
|
| 曝光埋点 | 记录推广位曝光(目前仅记录点击) |
|
||||||
|
| 后端捐赠单测 | 为 `donations.service.spec.ts` 补充用例(mock Prisma) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变现模式排序(无 ICP 证阶段)
|
||||||
|
|
||||||
|
1. **联盟返佣**(已落地)— 风险最低,第三方结算佣金
|
||||||
|
2. **打赏/个人码捐赠**(已落地)— 自愿赠与,平台不提供对价,不构成经营性收费
|
||||||
|
3. 免费内容 + SEO 引流
|
||||||
|
4. (取证后)会员订阅 / 付费 Skill / 企业版
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 版本 | 作者 | 变更摘要 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 2026-06-25 | v1.0.0 | Hermes Agent | 初始化进度文档 |
|
||||||
|
| 2026-07-11 | v1.1.0 | Hermes Agent | 规范化联盟数据模型+迁移+ORM服务+前端打通+测试+种子;明确无 ICP 证下的合规变现路径 |
|
||||||
|
| 2026-07-11 | v1.2.0 | Hermes Agent | 落地打赏(个人码捐赠)模式:Donation 模型+迁移+API+前端 /donate 页;会员/技能广场/用量包三处付费面改造为免费+赞助;导航页脚入口;i18n 中文化 |
|
||||||
|
| 2026-07-11 | v1.3.0 | Hermes Agent | 解决 SEO 盲点:课程/技能/文章/练习四详情页改为服务端取数 + initialData 渲染,正文写入静态 HTML;generateStaticParams 取真实 id;getX 校验 data.id 防 200 错误体;构建通过 |
|
||||||
+754
-549
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
@@ -74,6 +76,6 @@
|
|||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,118 +1,281 @@
|
|||||||
'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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContentDetailClient() {
|
// 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({ initialContent }: { initialContent?: Content | null }) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [content, setContent] = useState<Content | null>(null);
|
const [content, setContent] = useState<Content | null>(initialContent ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialContent);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [related, setRelated] = useState<Content[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialContent) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
fetch(`${API_BASE}/contents/${params.id}`)
|
fetch(`${API_BASE}/contents/${params.id}`)
|
||||||
.then(r => r.json())
|
.then((r) => {
|
||||||
.then(data => {
|
if (!r.ok) throw new Error('内容不存在');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
if (!data || !data.id) throw new Error('内容不存在');
|
if (!data || !data.id) throw new Error('内容不存在');
|
||||||
setContent(data);
|
setContent(data);
|
||||||
})
|
})
|
||||||
.catch(e => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id, initialContent]);
|
||||||
|
|
||||||
|
// Fetch related articles from same category (client-side; not required for SSR body)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!content?.id) return;
|
||||||
|
fetch(`${API_BASE}/contents?pageSize=5`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
const items: Content[] = d.items || [];
|
||||||
|
const currentCat = content.category?.id;
|
||||||
|
const currentId = content.id;
|
||||||
|
const filtered = items
|
||||||
|
.filter((i: Content) => i.id !== currentId)
|
||||||
|
.filter((i: Content) => !currentCat || i.category?.id === currentCat)
|
||||||
|
.slice(0, 3);
|
||||||
|
setRelated(filtered);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [content?.id]);
|
||||||
|
|
||||||
|
// Render markdown content synchronously so the body is present in static HTML
|
||||||
|
const renderedHtml = useMemo(() => {
|
||||||
|
if (!content?.content) return null;
|
||||||
|
try {
|
||||||
|
const result = marked(content.content, { breaks: true, gfm: true });
|
||||||
|
return typeof result === 'string' ? result : null;
|
||||||
|
} catch {
|
||||||
|
return 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" suppressHydrationWarning>
|
||||||
|
{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 && (
|
{/* Title */}
|
||||||
<p className="mt-2 text-sm text-muted-foreground">作者:{content.authorName}</p>
|
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground leading-tight mb-4">
|
||||||
)}
|
{content.title}
|
||||||
{content.summary && (
|
</h1>
|
||||||
<p className="mt-4 text-lg text-muted-foreground leading-relaxed">{content.summary}</p>
|
|
||||||
)}
|
{/* Meta row */}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground border-t border-border pt-4">
|
||||||
|
{content.authorName && <span>作者:{content.authorName}</span>}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
{content.viewCount} 次阅读
|
||||||
|
</span>
|
||||||
{content.tags && (
|
{content.tags && (
|
||||||
<div className="flex gap-2 mt-4 flex-wrap">
|
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||||||
{content.tags.split(',').map(tag => (
|
{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>
|
<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>
|
||||||
|
|
||||||
{content.cover && (
|
{/* Summary */}
|
||||||
<div className="mb-8 rounded-xl overflow-hidden bg-muted aspect-video flex items-center justify-center text-muted-foreground">
|
{content.summary && (
|
||||||
{content.cover.startsWith('http') ? (
|
<div className="mt-4 p-4 bg-muted/30 dark:bg-muted/20 rounded-xl border border-border">
|
||||||
<img src={content.cover} alt={content.title} className="w-full h-full object-cover" />
|
<p className="text-foreground leading-relaxed">
|
||||||
) : (
|
<span className="text-brand-600 font-medium mr-2">摘要</span>
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-brand-50 to-blue-50">
|
{content.summary}
|
||||||
<svg className="w-16 h-16 text-brand-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</p>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-12 pt-8 border-t border-border">
|
||||||
|
{/* Tags */}
|
||||||
|
{content.tags && (
|
||||||
|
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">标签:</span>
|
||||||
|
{content.tags.split(',').map(tag => (
|
||||||
|
<span key={tag} className="text-xs text-muted-foreground bg-muted/50 hover:bg-muted transition-colors px-2.5 py-1 rounded-full cursor-default">
|
||||||
|
{tag.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disclaimer */}
|
||||||
|
<div className="text-xs text-muted-foreground bg-muted/20 rounded-lg p-3">
|
||||||
|
📝 本文由宇之然AI 每日采集发布,内容整理自原文,仅供参考,版权归原文作者所有。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-border">
|
{/* Related articles */}
|
||||||
<Link href="/" className="text-brand-600 hover:text-brand-700 text-sm font-medium">
|
{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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
import { API_BASE } from '@/lib/config';
|
import type { Metadata } from 'next';
|
||||||
|
import { API_BASE, SITE_URL } from '@/lib/config';
|
||||||
|
import { articleJsonLd, breadcrumbJsonLd, absoluteUrl } from '@/lib/seo';
|
||||||
|
import ContentDetailClient from './client';
|
||||||
|
|
||||||
|
async function getContent(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/contents/${id}`, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
@@ -12,8 +27,45 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import ContentDetailClient from './client';
|
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||||
|
const content = await getContent(params.id);
|
||||||
export default function ContentDetailPage() {
|
if (!content) return { title: '文章未找到' };
|
||||||
return <ContentDetailClient />;
|
const title = content.title;
|
||||||
|
const description = content.summary || content.content?.slice(0, 120) || `${content.title} - 宇之然 AI 文章`;
|
||||||
|
const url = absoluteUrl(`/contents/${content.id}`);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: { canonical: `/contents/${content.id}` },
|
||||||
|
openGraph: { type: 'article', title, description, url },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ContentDetailPage({ params }: { params: { id: string } }) {
|
||||||
|
const content = await getContent(params.id);
|
||||||
|
const url = absoluteUrl(`/contents/${params.id}`);
|
||||||
|
const jsonLd = content
|
||||||
|
? [
|
||||||
|
articleJsonLd({
|
||||||
|
headline: content.title,
|
||||||
|
description: content.summary || undefined,
|
||||||
|
url,
|
||||||
|
datePublished: content.publishedAt || content.createdAt,
|
||||||
|
}),
|
||||||
|
breadcrumbJsonLd([
|
||||||
|
{ name: '首页', url: SITE_URL },
|
||||||
|
{ name: '文章', url: absoluteUrl('/contents') },
|
||||||
|
{ name: content.title, url },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jsonLd && (
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
)}
|
||||||
|
<ContentDetailClient initialContent={content} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'AI 文章与教程 - 宇之然 AI',
|
||||||
|
description: '宇之然 AI 精选文章与实战教程,涵盖 AI 通识、提示词工程、智能体、模型百科等,帮助你系统掌握人工智能。',
|
||||||
|
alternates: { canonical: '/contents' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContentsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -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} />)}
|
||||||
|
|||||||
@@ -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 } 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,49 +21,50 @@ interface Course {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseDetailClient() {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseDetailClient({ initialCourse }: { initialCourse?: Course | null }) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [course, setCourse] = useState<Course | null>(null);
|
const [course, setCourse] = useState<Course | null>(initialCourse ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialCourse);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
|
const [activeLesson, setActiveLesson] = useState<Lesson | null>(
|
||||||
|
initialCourse?.chapters?.[0]?.lessons?.[0] ?? null,
|
||||||
|
);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [paying, setPaying] = useState(false);
|
|
||||||
const [orderNo, setOrderNo] = useState('');
|
|
||||||
const [qrCodeUrl, setQrCodeUrl] = useState('');
|
|
||||||
const [payLoading, setPayLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialCourse) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${API_BASE}/courses/${params.id}`)
|
fetch(`${API_BASE}/courses/${params.id}`)
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (!data || !data.id) throw new Error('课程不存在');
|
if (!data || !data.id) throw new Error('课程不存在');
|
||||||
setCourse(data);
|
setCourse(data);
|
||||||
const firstLesson = data.chapters?.[0]?.lessons?.[0];
|
const firstLesson = data.chapters?.[0]?.lessons?.[0];
|
||||||
if (firstLesson) setActiveLesson(firstLesson);
|
if (firstLesson) setActiveLesson(firstLesson);
|
||||||
})
|
})
|
||||||
.catch(e => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id, initialCourse]);
|
||||||
|
|
||||||
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,49 +73,29 @@ 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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-card rounded-2xl p-8 max-w-md w-full text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-4">扫码支付</h3>
|
|
||||||
{qrUrl ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="bg-muted/50 rounded-xl p-6 mb-4 inline-block">
|
|
||||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}`} alt="支付二维码" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">请使用微信扫描二维码完成支付</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">正在生成支付二维码...</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => { setPaying(false); setQrCodeUrl(''); }}
|
|
||||||
className="px-4 py-2 border border-border rounded-lg text-sm hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<div className="flex h-[calc(100vh-4rem)] max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex h-[calc(100vh-4rem)]">
|
{/* Sidebar */}
|
||||||
<aside className={`${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} fixed md:relative md:translate-x-0 z-30 w-72 bg-card border-r border-border overflow-y-auto flex-shrink-0 transition-transform`}>
|
<aside className={`
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
|
fixed md:relative md:translate-x-0 z-30
|
||||||
|
w-72 bg-card border-r border-border
|
||||||
|
overflow-y-auto flex-shrink-0 transition-transform
|
||||||
|
`}>
|
||||||
<div className="p-4 border-b border-border">
|
<div className="p-4 border-b border-border">
|
||||||
<Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 block">
|
<Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 inline-flex items-center gap-1">
|
||||||
← 返回课程列表
|
<ChevronLeft className="w-3 h-3" />
|
||||||
|
返回课程列表
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-semibold text-foreground text-sm line-clamp-2">{course.title}</h2>
|
<h2 className="font-semibold text-foreground text-sm line-clamp-2 mt-2">{course.title}</h2>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{course.chapters.length} 章 · {totalLessons} 课时</p>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
<BookOpen className="w-3 h-3 inline mr-1 align-text-bottom" />
|
||||||
|
{course.chapters.length} 章 · {totalLessons} 课时
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="p-2">
|
<nav className="p-2">
|
||||||
{course.chapters.map((chapter, ci) => (
|
{course.chapters.map((chapter, ci) => (
|
||||||
<div key={chapter.id} className="mb-3">
|
<div key={chapter.id} className="mb-3">
|
||||||
@@ -126,7 +108,7 @@ export default function CourseDetailClient() {
|
|||||||
onClick={() => setActiveLesson(lesson)}
|
onClick={() => setActiveLesson(lesson)}
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
activeLesson?.id === lesson.id
|
activeLesson?.id === lesson.id
|
||||||
? 'bg-brand-50 text-brand-700 font-medium'
|
? 'bg-brand-50 text-brand-700 dark:bg-brand-950/30 dark:text-brand-400 font-medium'
|
||||||
: 'text-muted-foreground hover:bg-muted/50'
|
: 'text-muted-foreground hover:bg-muted/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -142,74 +124,56 @@ export default function CourseDetailClient() {
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="fixed inset-0 bg-black/20 z-20 md:hidden" onClick={() => setSidebarOpen(false)} />
|
<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">
|
<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">
|
<div className="sticky top-0 z-10 bg-card border-b border-border px-4 py-3 flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
className="md:hidden p-1.5 rounded-lg hover:bg-muted"
|
className="md:hidden p-1.5 rounded-lg hover:bg-muted"
|
||||||
|
aria-label="切换侧边栏"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Menu className="w-5 h-5 text-muted-foreground" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-sm font-medium text-foreground truncate">
|
<h1 className="text-sm font-medium text-foreground truncate">
|
||||||
{activeLesson?.title || course.title}
|
{activeLesson?.title || course.title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
{course.isFree ? (
|
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
|
||||||
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded">免费</span>
|
<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-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 && (
|
{course.category && (
|
||||||
<span className="bg-muted px-2 py-0.5 rounded">{course.category.name}</span>
|
<span className="bg-muted px-2 py-0.5 rounded">{course.category.name}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lesson content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{activeLesson ? (
|
{activeLesson ? (
|
||||||
<article className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
<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>
|
<h1 className="text-2xl font-bold text-foreground mb-6">{activeLesson.title}</h1>
|
||||||
<div className="prose prose-gray max-w-none">
|
<div className="text-card-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
{activeLesson.content ? (
|
{activeLesson.content ? (
|
||||||
<div className="text-foreground leading-relaxed whitespace-pre-wrap">
|
<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}
|
{activeLesson.content}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -221,9 +185,7 @@ export default function CourseDetailClient() {
|
|||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
|
<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">
|
<BookOpen className="w-8 h-8 text-muted-foreground" />
|
||||||
<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>
|
<p className="text-muted-foreground">请从左侧选择课时开始学习</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,5 +194,6 @@ export default function CourseDetailClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
import { API_BASE } from '@/lib/config';
|
import type { Metadata } from 'next';
|
||||||
|
import { API_BASE, SITE_URL } from '@/lib/config';
|
||||||
|
import { articleJsonLd, breadcrumbJsonLd, absoluteUrl } from '@/lib/seo';
|
||||||
|
import CourseDetailClient from './client';
|
||||||
|
|
||||||
|
async function getCourse(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/courses/${id}`, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
try {
|
try {
|
||||||
@@ -12,8 +27,49 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import CourseDetailClient from './client';
|
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||||
|
const course = await getCourse(params.id);
|
||||||
export default function CourseDetailPage() {
|
if (!course) return { title: '课程未找到' };
|
||||||
return <CourseDetailClient />;
|
const title = course.title;
|
||||||
|
const description = course.description || `${course.title} - 宇之然 AI 课程,系统学习 AI 技能`;
|
||||||
|
const url = absoluteUrl(`/courses/${course.id}`);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: { canonical: `/courses/${course.id}` },
|
||||||
|
openGraph: {
|
||||||
|
type: 'article',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CourseDetailPage({ params }: { params: { id: string } }) {
|
||||||
|
const course = await getCourse(params.id);
|
||||||
|
const url = absoluteUrl(`/courses/${params.id}`);
|
||||||
|
const jsonLd = course
|
||||||
|
? [
|
||||||
|
articleJsonLd({
|
||||||
|
headline: course.title,
|
||||||
|
description: course.description || undefined,
|
||||||
|
url,
|
||||||
|
}),
|
||||||
|
breadcrumbJsonLd([
|
||||||
|
{ name: '首页', url: SITE_URL },
|
||||||
|
{ name: '课程', url: absoluteUrl('/courses') },
|
||||||
|
{ name: course.title, url },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jsonLd && (
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
)}
|
||||||
|
<CourseDetailClient initialCourse={course} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import CoursesPage from '../app/courses/page';
|
import CoursesPage from '../page';
|
||||||
|
|
||||||
describe('Courses Page', () => {
|
describe('Courses Page', () => {
|
||||||
it('should render courses page', () => {
|
it('should render courses page', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Heart, QrCode, MessageCircle, Send } from 'lucide-react';
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
import { apiFetch } from '@/lib/auth';
|
||||||
|
import { useT } from '@/i18n';
|
||||||
|
|
||||||
|
const ALIPAY_QR = process.env.NEXT_PUBLIC_DONATE_ALIPAY_QR || '';
|
||||||
|
const WECHAT_QR = process.env.NEXT_PUBLIC_DONATE_WECHAT_QR || '';
|
||||||
|
|
||||||
|
interface Donation {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
message: string | null;
|
||||||
|
channel: string;
|
||||||
|
amount: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(t: ReturnType<typeof useT>, channel: string) {
|
||||||
|
if (channel === 'WECHAT') return t.donate.wechat;
|
||||||
|
return t.donate.alipay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QrCard({
|
||||||
|
t,
|
||||||
|
label,
|
||||||
|
src,
|
||||||
|
}: {
|
||||||
|
t: ReturnType<typeof useT>;
|
||||||
|
label: string;
|
||||||
|
src: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="p-6 flex flex-col items-center text-center">
|
||||||
|
<div className="w-12 h-12 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<QrCode className="w-6 h-6 text-rose-600 dark:text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-foreground mb-1">{label}</h3>
|
||||||
|
{src ? (
|
||||||
|
<div className="mt-3 border border-border rounded-xl p-3 bg-white">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={src} alt={label} className="w-44 h-44 object-contain mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">{t.donate.noQr}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DonatePage() {
|
||||||
|
const t = useT();
|
||||||
|
const [donations, setDonations] = useState<Donation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [channel, setChannel] = useState<'ALIPAY' | 'WECHAT'>('ALIPAY');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/donations`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setDonations(data.donations || []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`${API_BASE}/donations`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim() || undefined,
|
||||||
|
message: message.trim() || undefined,
|
||||||
|
channel,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setDone(true);
|
||||||
|
setName('');
|
||||||
|
setMessage('');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.donation) {
|
||||||
|
setDonations((prev) => [data.donation, ...prev].slice(0, 20));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<div className="inline-flex w-14 h-14 bg-rose-100 dark:bg-rose-900/30 rounded-2xl items-center justify-center mb-4">
|
||||||
|
<Heart className="w-7 h-7 text-rose-600 dark:text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{t.donate.title}</h1>
|
||||||
|
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.donate.desc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-8 mb-8 text-center bg-gradient-to-br from-rose-50 to-white dark:from-rose-950/20 dark:to-background">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{t.donate.supportTitle}</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.donate.supportDesc}</p>
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">{t.donate.qrNote}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-10">
|
||||||
|
<QrCard t={t} label={t.donate.alipay} src={ALIPAY_QR} />
|
||||||
|
<QrCard t={t} label={t.donate.wechat} src={WECHAT_QR} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6 mb-10">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MessageCircle className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t.donate.leaveMessage}</h2>
|
||||||
|
</div>
|
||||||
|
{done ? (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-green-600 dark:text-green-400 font-medium">{t.donate.submitSuccess}</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => setDone(false)}>
|
||||||
|
{t.donate.leaveMessage}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t.donate.namePlaceholder}
|
||||||
|
maxLength={40}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder={t.donate.messagePlaceholder}
|
||||||
|
maxLength={280}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground outline-none focus:border-brand-500 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{t.donate.channel}</span>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="channel"
|
||||||
|
checked={channel === 'ALIPAY'}
|
||||||
|
onChange={() => setChannel('ALIPAY')}
|
||||||
|
/>
|
||||||
|
{t.donate.alipay}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="channel"
|
||||||
|
checked={channel === 'WECHAT'}
|
||||||
|
onChange={() => setChannel('WECHAT')}
|
||||||
|
/>
|
||||||
|
{t.donate.wechat}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{t.donate.submitFailed}</p>}
|
||||||
|
<Button type="submit" disabled={submitting} className="gap-2">
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
{submitting ? t.donate.submitting : t.donate.submit}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">{t.donate.thanksWall}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">{t.donate.thanksWallDesc}</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : donations.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center text-muted-foreground">{t.common.noData}</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{donations.map((d) => (
|
||||||
|
<Card key={d.id} className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{d.name || t.donate.anonymous}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t.donate.via.replace('{channel}', channelLabel(t, d.channel))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{d.message && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.donate.says}:{d.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-10 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||||
|
{t.donate.disclaimer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,26 +9,110 @@ 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': '/',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
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,
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: '宇之然 AI - AI 工具与知识社区',
|
||||||
|
description: '让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战',
|
||||||
|
},
|
||||||
|
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">
|
||||||
|
|||||||
@@ -2,16 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { apiFetch } from '@/lib/auth';
|
|
||||||
import { useAuth } from '@/lib/auth-context';
|
|
||||||
import PaymentModal from '@/components/ui/payment-modal';
|
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
import { ShoppingBag, CheckCircle, Lock, Sparkles } from 'lucide-react';
|
import { ShoppingBag, CheckCircle, Heart } from 'lucide-react';
|
||||||
|
|
||||||
interface MarketplaceSkill {
|
interface MarketplaceSkill {
|
||||||
id: string; name: string; description: string; icon: string;
|
id: string; name: string; description: string; icon: string;
|
||||||
@@ -19,132 +15,55 @@ interface MarketplaceSkill {
|
|||||||
price: number | null; purchased: boolean; sortOrder: number;
|
price: number | null; purchased: boolean; sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterMode = 'all' | 'free' | 'premium' | 'purchased';
|
|
||||||
|
|
||||||
export default function MarketplacePage() {
|
export default function MarketplacePage() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
const { isLoggedIn } = useAuth();
|
|
||||||
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
|
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState<FilterMode>('all');
|
|
||||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
const [paymentModal, setPaymentModal] = useState<{
|
|
||||||
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
|
|
||||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = `${API_BASE}/skills/marketplace`;
|
const url = `${API_BASE}/skills/marketplace`;
|
||||||
fetch(url, { credentials: 'include' })
|
fetch(url, { credentials: 'include' })
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setSkills(Array.isArray(data) ? data : []);
|
setSkills(Array.isArray(data) ? data : []);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = skills.filter(s => {
|
|
||||||
if (filter === 'free') return !s.price;
|
|
||||||
if (filter === 'premium') return !!s.price;
|
|
||||||
if (filter === 'purchased') return s.purchased;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleBuy(skill: MarketplaceSkill) {
|
|
||||||
if (!isLoggedIn) { router.push('/auth'); return; }
|
|
||||||
setPayLoading(skill.id);
|
|
||||||
setSuccess(null);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/orders/create', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
amount: skill.price,
|
|
||||||
planType: 'SKILL',
|
|
||||||
skillId: skill.id,
|
|
||||||
payChannel: 'alipay',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.order && data.payResult) {
|
|
||||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
|
||||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
|
||||||
if (payUrl || qrCode) {
|
|
||||||
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel: 'alipay' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
setPayLoading(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaymentPaid() {
|
|
||||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
|
||||||
setSuccess(t.marketplace.purchaseSuccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters: { key: FilterMode; label: string }[] = [
|
|
||||||
{ key: 'all', label: t.common.viewAll },
|
|
||||||
{ key: 'free', label: t.marketplace.free },
|
|
||||||
{ key: 'premium', label: t.marketplace.locked },
|
|
||||||
{ key: 'purchased', label: t.marketplace.purchased },
|
|
||||||
];
|
|
||||||
|
|
||||||
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-8">
|
<div className="mb-6">
|
||||||
<h1 className="text-3xl font-bold text-foreground">{t.marketplace.title}</h1>
|
<h1 className="text-3xl font-bold text-foreground">{t.marketplace.title}</h1>
|
||||||
<p className="mt-2 text-muted-foreground">{t.marketplace.desc}</p>
|
<p className="mt-2 text-muted-foreground">{t.marketplace.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-8">
|
<div className="flex items-center justify-between gap-3 bg-rose-50 dark:bg-rose-950/20 border border-rose-200 dark:border-rose-900 rounded-xl p-4 mb-8">
|
||||||
{filters.map(f => (
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<Heart className="w-5 h-5 text-rose-600 dark:text-rose-400 shrink-0" />
|
||||||
key={f.key}
|
<p className="text-sm text-foreground">{t.marketplace.freeNote}</p>
|
||||||
onClick={() => setFilter(f.key)}
|
</div>
|
||||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
<Link
|
||||||
filter === f.key
|
href="/donate"
|
||||||
? 'bg-foreground text-background'
|
className="shrink-0 text-sm font-medium text-rose-600 dark:text-rose-400 hover:underline"
|
||||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{f.label}
|
{t.donate.toDonate} →
|
||||||
</button>
|
</Link>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
|
|
||||||
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<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">
|
||||||
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-52 rounded-2xl" />)}
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-52 rounded-2xl" />
|
||||||
|
))}
|
||||||
</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">
|
||||||
{filtered.map(skill => {
|
{skills.map((skill) => (
|
||||||
const isPremium = !!skill.price;
|
<div
|
||||||
const isOwned = skill.purchased;
|
key={skill.id}
|
||||||
return (
|
className="bg-card rounded-2xl border-2 border-border p-6 transition-all hover:shadow-md flex flex-col"
|
||||||
<div key={skill.id}
|
|
||||||
className={`bg-card rounded-2xl border-2 p-6 transition-all hover:shadow-md flex flex-col ${
|
|
||||||
isPremium && !isOwned ? 'border-amber-500/40' : 'border-border'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isPremium && !isOwned && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 mb-2">
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
<span>{t.marketplace.locked}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 mb-3">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<span className="text-3xl">{skill.icon}</span>
|
<span className="text-3xl">{skill.icon}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -154,86 +73,50 @@ export default function MarketplacePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
<span
|
||||||
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
|
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
|
skill.difficulty === 'beginner'
|
||||||
'bg-red-100 text-red-700'
|
? 'bg-green-100 text-green-700'
|
||||||
}`}>
|
: skill.difficulty === 'intermediate'
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{(t.skills as any)[skill.difficulty]}
|
{(t.skills as any)[skill.difficulty]}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{(t.skills.categories as any)[skill.category] || skill.category}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1 mb-4">
|
<div className="flex flex-wrap gap-1 mb-4">
|
||||||
{skill.tags.slice(0, 3).map(tag => (
|
{skill.tags.slice(0, 3).map((tag) => (
|
||||||
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{tag}</span>
|
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center gap-2">
|
<div className="mt-auto flex items-center gap-2">
|
||||||
{isOwned ? (
|
|
||||||
<>
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
<CheckCircle className="h-3.5 w-3.5" />
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
{t.marketplace.purchased}
|
{t.marketplace.free}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button asChild size="sm" variant="default" className="ml-auto">
|
<Button asChild size="sm" variant="default" className="ml-auto">
|
||||||
<Link href={`/sandbox?skill=${skill.id}`}>
|
<Link href={`/sandbox?skill=${skill.id}`}>{t.skills.apply}</Link>
|
||||||
{t.skills.apply}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
) : isPremium ? (
|
|
||||||
<>
|
|
||||||
<span className="text-lg font-bold text-foreground">¥{skill.price}</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleBuy(skill)}
|
|
||||||
disabled={payLoading === skill.id}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
{payLoading === skill.id ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="animate-spin h-3 w-3 border-2 border-current border-t-transparent rounded-full" />
|
|
||||||
{t.marketplace.buying}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<ShoppingBag className="h-3.5 w-3.5" />
|
|
||||||
{t.marketplace.buy.replace('{price}', String(skill.price))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button asChild size="sm" variant="outline" className="ml-auto">
|
|
||||||
<Link href={`/sandbox?skill=${skill.id}`}>
|
|
||||||
{t.skills.apply}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && skills.length === 0 && (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<ShoppingBag className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<ShoppingBag className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p className="text-muted-foreground">{t.common.noData}</p>
|
<p className="text-muted-foreground">{t.common.noData}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PaymentModal
|
|
||||||
open={paymentModal.open}
|
|
||||||
orderNo={paymentModal.orderNo}
|
|
||||||
payResult={paymentModal.payResult}
|
|
||||||
payChannel={paymentModal.payChannel}
|
|
||||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
|
||||||
onPaid={handlePaymentPaid}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'AI 模型能力导览 - 宇之然 AI',
|
||||||
|
description: '探索 AI 能为你做什么,从对话写作到编程实战,按能力场景找到适合你的 AI 模型与使用方式。',
|
||||||
|
alternates: { canonical: '/models' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ModelsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,240 +4,101 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { apiFetch } from '../../../lib/auth';
|
import { apiFetch } from '../../../lib/auth';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import PaymentModal from '@/components/ui/payment-modal';
|
import { Card } from '@/components/ui/card';
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
import { toast } from 'sonner';
|
import { Heart, Link2, Sparkles } from 'lucide-react';
|
||||||
import { isWeChatBrowser } from '@/lib/wechat';
|
|
||||||
|
|
||||||
interface PayResult {
|
|
||||||
gatewayOrderId?: string;
|
|
||||||
payUrl?: string;
|
|
||||||
qrcode?: string;
|
|
||||||
codeUrl?: string;
|
|
||||||
redirectUrl?: string;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Subscription {
|
|
||||||
id: number; plan: string; startDate: string; endDate: string; status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Order {
|
|
||||||
id: number; orderNo: string; amount: number; planType: string; status: string; payChannel?: string; createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLANS = [
|
|
||||||
{ id: 'FREE', nameKey: 'planFree', price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] },
|
|
||||||
{ id: 'MONTHLY', nameKey: 'planMonthly', price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] },
|
|
||||||
{ id: 'YEARLY', nameKey: 'planYearly', price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const FEATURE_LABELS = ['featureSandbox', 'featureModels', 'featurePrompts', 'featureCourses', 'featureAds'] as const;
|
|
||||||
|
|
||||||
export default function MemberPage() {
|
export default function MemberPage() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
|
||||||
const [orders, setOrders] = useState<Order[]>([]);
|
|
||||||
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
|
const [quota, setQuota] = useState<{ used: number; remaining: number; dailyLimit: number } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
|
||||||
const [payChannel, setPayChannel] = useState<'wxpay' | 'alipay'>('alipay');
|
|
||||||
const [paymentModal, setPaymentModal] = useState<{
|
|
||||||
open: boolean; orderNo: string; payResult: PayResult; payChannel: 'wxpay' | 'alipay';
|
|
||||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, []);
|
useEffect(() => {
|
||||||
|
apiFetch('/sandbox/quota')
|
||||||
async function loadData() {
|
.then((r) => r.json())
|
||||||
try {
|
.then((data) => {
|
||||||
const [subRes, ordersRes, quotaRes] = await Promise.all([
|
if (data.remaining !== undefined) setQuota(data);
|
||||||
apiFetch('/subscriptions/current').catch(() => ({ ok: false })),
|
})
|
||||||
apiFetch('/orders'),
|
.catch(() => {})
|
||||||
apiFetch('/sandbox/quota'),
|
.finally(() => setLoading(false));
|
||||||
]);
|
}, []);
|
||||||
if (subRes.ok) setSubscription(await (subRes as Response).json());
|
|
||||||
const ordersData = await ordersRes.json();
|
|
||||||
setOrders(ordersData.items || []);
|
|
||||||
const quotaData = await quotaRes.json();
|
|
||||||
if (quotaData.remaining !== undefined) setQuota(quotaData);
|
|
||||||
} catch { toast.error("加载失败") }
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubscribe(planType: string) {
|
|
||||||
setPayLoading(planType);
|
|
||||||
try {
|
|
||||||
const body: Record<string, any> = {
|
|
||||||
amount: planType === 'MONTHLY' ? 49.9 : 299,
|
|
||||||
planType,
|
|
||||||
payChannel,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await apiFetch('/orders/create', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.order && data.payResult) {
|
|
||||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
|
||||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
|
||||||
|
|
||||||
if (payUrl || qrCode) {
|
|
||||||
setPaymentModal({
|
|
||||||
open: true,
|
|
||||||
orderNo: data.order.orderNo,
|
|
||||||
payResult: data.payResult,
|
|
||||||
payChannel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { toast.error("加载失败") }
|
|
||||||
setPayLoading(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaymentPaid() {
|
|
||||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<Skeleton className="h-8 w-48 mb-2" />
|
<Skeleton className="h-8 w-48 mb-2" />
|
||||||
<Skeleton className="h-5 w-64 mb-8" />
|
<Skeleton className="h-5 w-64 mb-8" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<Skeleton className="h-40 rounded-2xl mb-6" />
|
||||||
{[1,2,3].map(i => <Skeleton key={i} className="h-72 rounded-2xl" />)}
|
<Skeleton className="h-48 rounded-2xl" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">← 返回我的</Link>
|
<Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">← {t.myLearning.back}</Link>
|
||||||
<h1 className="text-3xl font-bold text-foreground">{t.member.title}</h1>
|
<h1 className="text-3xl font-bold text-foreground">{t.member.title}</h1>
|
||||||
<p className="mt-2 text-muted-foreground">{t.member.desc}</p>
|
<p className="mt-2 text-muted-foreground">{t.member.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quota && (
|
{quota && (
|
||||||
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
|
<Card className="p-6 mb-6">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-medium text-foreground">{t.member.dailyQuota}</span>
|
<span className="text-sm font-medium text-foreground">{t.member.dailyQuota}</span>
|
||||||
<span className="text-sm text-muted-foreground">{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
</div>
|
{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}
|
||||||
<div className="w-full bg-muted rounded-full h-3">
|
|
||||||
<div className="bg-brand-600 h-3 rounded-full transition-all" style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
||||||
{PLANS.map(plan => {
|
|
||||||
const isCurrent = subscription?.plan === plan.id;
|
|
||||||
return (
|
|
||||||
<div key={plan.id} className={`bg-card rounded-2xl border-2 p-6 flex flex-col ${isCurrent ? 'border-brand-600' : plan.popular ? 'border-brand-400' : 'border-border'}`}>
|
|
||||||
{plan.popular && !isCurrent && (
|
|
||||||
<span className="self-start text-xs font-medium px-2 py-0.5 bg-brand-600 text-white rounded-full mb-3">{t.member.popular}</span>
|
|
||||||
)}
|
|
||||||
{isCurrent && (
|
|
||||||
<span className="self-start text-xs font-medium px-2 py-0.5 bg-green-600 text-white rounded-full mb-3">{t.member.currentPlan_badge}</span>
|
|
||||||
)}
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
|
|
||||||
<div className="mb-4">
|
|
||||||
{plan.price > 0 ? (
|
|
||||||
<span className="text-3xl font-bold text-foreground">{plan.price === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{plan.price > 0 ? t.member[plan.period as keyof typeof t.member] : ''}</span></span>
|
|
||||||
) : (
|
|
||||||
<span className="text-2xl font-bold text-foreground">¥0</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 flex-1 mb-6">
|
|
||||||
{(plan.id === 'FREE' ? FEATURE_LABELS : FEATURE_LABELS).map((f, fi) => (
|
|
||||||
<div key={f} className="grid grid-cols-[1fr_auto] gap-x-2 text-sm">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="text-green-600 text-xs shrink-0">✓</span>
|
|
||||||
<span className="text-muted-foreground truncate">{t.member[f]}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-foreground font-medium text-right">{t.member[plan.features[fi]]}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{plan.id !== 'FREE' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{!isCurrent && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => { setPayChannel('alipay'); handleSubscribe(plan.id); }}
|
|
||||||
disabled={payLoading === plan.id}
|
|
||||||
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
|
|
||||||
plan.popular
|
|
||||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
|
||||||
: 'border border-border text-foreground hover:bg-accent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{payLoading === plan.id ? t.member.processing : t.member.alipay}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setPayChannel('wxpay'); handleSubscribe(plan.id); }}
|
|
||||||
disabled={payLoading === plan.id}
|
|
||||||
className={`flex-1 py-2.5 rounded-xl text-sm font-medium transition-all disabled:opacity-50 ${
|
|
||||||
plan.popular
|
|
||||||
? 'bg-brand-600 text-white hover:bg-brand-700'
|
|
||||||
: 'border border-border text-foreground hover:bg-accent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{payLoading === plan.id ? t.member.processing : t.member.wechatPay}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isCurrent && (
|
|
||||||
<button disabled
|
|
||||||
className="w-full py-2.5 rounded-xl text-sm font-medium bg-muted text-muted-foreground cursor-default">
|
|
||||||
{t.member.currentPlan_badge}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-card rounded-2xl border border-border p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-4">{t.member.orderHistory}</h2>
|
|
||||||
{orders.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-center py-8">{t.member.noOrders}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{orders.map(order => (
|
|
||||||
<div key={order.id} className="flex items-center justify-between p-4 rounded-xl border border-border">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-foreground">{t.member[order.planType === 'YEARLY' ? 'yearly' : 'monthly']}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{new Date(order.createdAt).toLocaleDateString()}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-semibold text-foreground">¥{order.amount}</div>
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
{order.payChannel && (
|
|
||||||
<span className="text-xs text-muted-foreground">{order.payChannel === 'alipay' ? '支付宝' : '微信'}</span>
|
|
||||||
)}
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${order.status === 'PAID' ? 'bg-green-100 text-green-700' : order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : 'bg-muted text-muted-foreground'}`}>
|
|
||||||
{order.status}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="w-full bg-muted rounded-full h-3">
|
||||||
</div>
|
<div
|
||||||
))}
|
className="bg-brand-600 h-3 rounded-full transition-all"
|
||||||
</div>
|
style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<PaymentModal
|
|
||||||
open={paymentModal.open}
|
|
||||||
orderNo={paymentModal.orderNo}
|
|
||||||
payResult={paymentModal.payResult}
|
|
||||||
payChannel={paymentModal.payChannel}
|
|
||||||
onPaid={handlePaymentPaid}
|
|
||||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="p-8 mb-6 bg-gradient-to-br from-brand-50 to-white dark:from-brand-950/20 dark:to-background text-center">
|
||||||
|
<div className="inline-flex w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-2xl items-center justify-center mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{t.member.freeUser}</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.member.benefits}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Link href="/donate" className="block">
|
||||||
|
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center">
|
||||||
|
<Heart className="w-5 h-5 text-rose-600 dark:text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-foreground">{t.donate.supportUs}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground flex-1">{t.donate.supportDesc}</p>
|
||||||
|
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
|
||||||
|
{t.donate.toDonate} →
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href="/affiliate" className="block">
|
||||||
|
<Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
|
||||||
|
<div className="flex items-center 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">
|
||||||
|
<Link2 className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-foreground">{t.donate.affiliate}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground flex-1">{t.affiliate.desc}</p>
|
||||||
|
<span className="mt-4 text-sm font-medium text-brand-600 dark:text-brand-400">
|
||||||
|
{t.affiliate.title} →
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-8 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||||
|
{t.donate.disclaimer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
|
export default function OGImage() {
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '80px',
|
||||||
|
background: 'linear-gradient(135deg, #0c8ee7 0%, #6d28d9 100%)',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', fontSize: 38, opacity: 0.92, letterSpacing: 1 }}>
|
||||||
|
Yuzhiran AI
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 84, fontWeight: 800, marginTop: 28, lineHeight: 1.1 }}>
|
||||||
|
AI Tools & Knowledge
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 84, fontWeight: 800, lineHeight: 1.1 }}>
|
||||||
|
Community
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 34, marginTop: 30, opacity: 0.92 }}>
|
||||||
|
Tools, Prompts, Skills & Sandbox for everyone
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size },
|
||||||
|
);
|
||||||
|
}
|
||||||
+163
-51
@@ -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">
|
||||||
|
<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>
|
</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>
|
||||||
</Card>
|
{highlightMatch && (
|
||||||
</a>
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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: '打开沙盒' },
|
||||||
|
{ icon: FileText, title: '提示词工程', desc: '学习编写高效提示词,掌握与 AI 沟通的最佳实践', href: '/prompts', cta: '浏览提示词库' },
|
||||||
|
{ icon: GraduationCap, title: '技能实战', desc: '在真实场景中练习 AI 技能,获得即时评分反馈', href: '/practices', cta: '开始练习' },
|
||||||
|
{ icon: Puzzle, title: '工作流技能', desc: '免费使用的 AI 工作流技能,解决具体业务问题', href: '/marketplace', cta: '前往技能广场' },
|
||||||
|
].map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link key={item.title} href={item.href} className="group bg-card rounded-xl border border-border p-6 hover:border-brand-200 hover:shadow-sm transition-all">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-brand-50 dark:bg-brand-950 flex items-center justify-center mb-4 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 transition-colors">
|
||||||
|
<Icon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">{feat.title}</h3>
|
<h3 className="text-lg font-semibold text-foreground mb-2">{item.title}</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{feat.desc}</p>
|
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">{item.desc}</p>
|
||||||
</div>
|
<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>
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ interface Submission {
|
|||||||
question: PracticeQuestion;
|
question: PracticeQuestion;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PracticeDetailPage() {
|
export default function PracticeDetailPage({ initialQuestion }: { initialQuestion?: PracticeQuestion | null }) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
const [question, setQuestion] = useState<PracticeQuestion | null>(null);
|
const [question, setQuestion] = useState<PracticeQuestion | null>(initialQuestion ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialQuestion);
|
||||||
const [answer, setAnswer] = useState('');
|
const [answer, setAnswer] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submission, setSubmission] = useState<Submission | null>(null);
|
const [submission, setSubmission] = useState<Submission | null>(null);
|
||||||
@@ -60,14 +60,15 @@ export default function PracticeDetailPage() {
|
|||||||
const [startTime] = useState(Date.now());
|
const [startTime] = useState(Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialQuestion) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
fetch(`${API_BASE}/practices/${params.id}`)
|
fetch(`${API_BASE}/practices/${params.id}`)
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data.id) setQuestion(data);
|
if (data.id) setQuestion(data);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id, initialQuestion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn || !params.id) return;
|
if (!isLoggedIn || !params.id) return;
|
||||||
|
|||||||
@@ -1,10 +1,66 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { API_BASE, SITE_URL } from '@/lib/config';
|
||||||
|
import { articleJsonLd, breadcrumbJsonLd, absoluteUrl } from '@/lib/seo';
|
||||||
import PracticeDetailClient from './client';
|
import PracticeDetailClient from './client';
|
||||||
|
|
||||||
export function generateStaticParams() {
|
async function getPractice(id: string) {
|
||||||
const ids = Array.from({ length: 20 }, (_, i) => i + 1);
|
try {
|
||||||
return ids.map(id => ({ id: String(id) }));
|
const res = await fetch(`${API_BASE}/practices/${id}`, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PracticeDetailPage() {
|
export async function generateStaticParams() {
|
||||||
return <PracticeDetailClient />;
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/practices?pageSize=200`, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
return items.map((p: { id: number | string }) => ({ id: String(p.id) }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||||
|
const practice = await getPractice(params.id);
|
||||||
|
if (!practice) return { title: '练习未找到' };
|
||||||
|
const title = practice.title;
|
||||||
|
const description = practice.description || `${practice.title} - 宇之然 AI 练习,实战演练获得即时评分`;
|
||||||
|
const url = absoluteUrl(`/practices/${practice.id}`);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: { canonical: `/practices/${practice.id}` },
|
||||||
|
openGraph: { type: 'article', title, description, url },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PracticeDetailPage({ params }: { params: { id: string } }) {
|
||||||
|
const practice = await getPractice(params.id);
|
||||||
|
const url = absoluteUrl(`/practices/${params.id}`);
|
||||||
|
const jsonLd = practice
|
||||||
|
? [
|
||||||
|
articleJsonLd({ headline: practice.title, description: practice.description || undefined, url }),
|
||||||
|
breadcrumbJsonLd([
|
||||||
|
{ name: '首页', url: SITE_URL },
|
||||||
|
{ name: '练习', url: absoluteUrl('/practices') },
|
||||||
|
{ name: practice.title, url },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jsonLd && (
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
)}
|
||||||
|
<PracticeDetailClient initialQuestion={practice} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'AI 实战练习 - 宇之然 AI',
|
||||||
|
description: '通过真实场景练习 AI 技能,提交答案获得即时评分与反馈,覆盖编程、写作、数据分析等多种场景。',
|
||||||
|
alternates: { canonical: '/practices' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PracticesLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
import { apiFetch } from '@/lib/auth';
|
import { apiFetch } from '@/lib/auth';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import PaymentModal from '@/components/ui/payment-modal';
|
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
import { Zap, CheckCircle } from 'lucide-react';
|
import { Zap, Heart, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
interface Quota {
|
interface Quota {
|
||||||
dailyLimit: number;
|
dailyLimit: number;
|
||||||
@@ -18,79 +18,45 @@ interface Quota {
|
|||||||
totalRemaining: number;
|
totalRemaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PACKAGES = [
|
|
||||||
{ id: 'PACKAGE_10', planType: 'PACKAGE', amount: 4.9, quotaAmount: 10, labelKey: 'package10' as const, descKey: 'package10Desc' as const },
|
|
||||||
{ id: 'PACKAGE_50', planType: 'PACKAGE', amount: 9.9, quotaAmount: 50, labelKey: 'package50' as const, descKey: 'package50Desc' as const, popular: true },
|
|
||||||
{ id: 'PACKAGE_300', planType: 'PACKAGE', amount: 49, quotaAmount: 300, labelKey: 'package300' as const, descKey: 'package300Desc' as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PackagesPage() {
|
export default function PackagesPage() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
const [quota, setQuota] = useState<Quota | null>(null);
|
const [quota, setQuota] = useState<Quota | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [payLoading, setPayLoading] = useState<string | null>(null);
|
|
||||||
const [payChannel] = useState<'wxpay' | 'alipay'>('alipay');
|
|
||||||
const [paymentModal, setPaymentModal] = useState<{
|
|
||||||
open: boolean; orderNo: string; payResult: any; payChannel: 'wxpay' | 'alipay';
|
|
||||||
}>({ open: false, orderNo: '', payResult: {}, payChannel: 'alipay' });
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn) { setLoading(false); return; }
|
if (!isLoggedIn) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
apiFetch('/sandbox/quota')
|
apiFetch('/sandbox/quota')
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => setQuota(data))
|
.then((data) => setQuota(data))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
async function handleBuy(pkg: typeof PACKAGES[0]) {
|
|
||||||
setPayLoading(pkg.id);
|
|
||||||
setSuccess(null);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/orders/create', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ amount: pkg.amount, planType: pkg.planType, payChannel }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.order && data.payResult) {
|
|
||||||
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
|
|
||||||
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
|
|
||||||
|
|
||||||
if (payUrl || qrCode) {
|
|
||||||
setPaymentModal({ open: true, orderNo: data.order.orderNo, payResult: data.payResult, payChannel });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
setPayLoading(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaymentPaid() {
|
|
||||||
setPaymentModal(prev => ({ ...prev, open: false }));
|
|
||||||
setSuccess(t.packages.purchaseSuccess.replace('{n}', '50'));
|
|
||||||
apiFetch('/sandbox/quota').then(r => r.ok && r.json()).then(d => d && setQuota(d));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
|
||||||
<Zap className="h-12 w-12 text-brand-600 mx-auto mb-4" />
|
<Zap className="h-12 w-12 text-brand-600 mx-auto mb-4" />
|
||||||
<h1 className="text-2xl font-bold text-foreground mb-2">{t.packages.title}</h1>
|
<h1 className="text-2xl font-bold text-foreground mb-2">{t.packages.title}</h1>
|
||||||
<p className="text-muted-foreground mb-6">{t.packages.desc}</p>
|
<p className="text-muted-foreground mb-6">{t.packages.desc}</p>
|
||||||
<Button asChild><Link href="/auth">{t.common.login}</Link></Button>
|
<Button asChild>
|
||||||
|
<Link href="/auth">{t.common.login}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<Link href="/practices" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block">
|
<Link
|
||||||
← {t.practices.title}
|
href="/practices"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-block"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 inline mr-1" />
|
||||||
|
{t.practices.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -98,77 +64,47 @@ export default function PackagesPage() {
|
|||||||
<p className="mt-2 text-muted-foreground">{t.packages.desc}</p>
|
<p className="mt-2 text-muted-foreground">{t.packages.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quota && (
|
{loading ? (
|
||||||
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
|
<Skeleton className="h-32 rounded-2xl mb-8" />
|
||||||
|
) : (
|
||||||
|
quota && (
|
||||||
|
<Card className="p-6 mb-8">
|
||||||
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
|
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
|
||||||
<div className="flex flex-wrap gap-6">
|
<div className="flex flex-wrap gap-6">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-muted-foreground">{t.packages.dailyQuota.replace('{used}', String(quota.used)).replace('{limit}', String(quota.dailyLimit))}</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t.packages.dailyQuota
|
||||||
|
.replace('{used}', String(quota.used))
|
||||||
|
.replace('{limit}', String(quota.dailyLimit))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-muted-foreground">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
</div>
|
{t.packages.extraQuota.replace('{n}', String(quota.extra))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
<Card className="p-8 mb-8 bg-gradient-to-br from-rose-50 to-white dark:from-rose-950/20 dark:to-background text-center">
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
|
<div className="inline-flex w-12 h-12 bg-rose-100 dark:bg-rose-900/30 rounded-2xl items-center justify-center mb-4">
|
||||||
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
|
<Heart className="w-6 h-6 text-rose-600 dark:text-rose-400" />
|
||||||
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<h2 className="text-xl font-semibold text-foreground">{t.donate.supportTitle}</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.donate.supportDesc}</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<Button asChild className="mt-5 gap-2">
|
||||||
{PACKAGES.map(pkg => (
|
<Link href="/donate">
|
||||||
<div key={pkg.id} className={`bg-card rounded-2xl border-2 p-6 relative transition-all hover:shadow-md ${
|
<Heart className="w-4 h-4" />
|
||||||
pkg.popular ? 'border-brand-600' : 'border-border'
|
{t.donate.toDonate}
|
||||||
}`}>
|
</Link>
|
||||||
{pkg.popular && (
|
|
||||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand-600 text-white text-xs font-medium px-3 py-0.5 rounded-full">
|
|
||||||
{t.packages.popular}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<div className="text-3xl font-bold text-foreground">{t.packages.price.replace('{price}', String(pkg.amount))}</div>
|
|
||||||
<div className="text-sm text-muted-foreground mt-1">{t.packages.quota.replace('{n}', String(pkg.quotaAmount))}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<div className="font-medium text-foreground">{t.packages[pkg.labelKey]}</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">{t.packages[pkg.descKey]}</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleBuy(pkg)}
|
|
||||||
disabled={payLoading === pkg.id}
|
|
||||||
className={`w-full ${pkg.popular ? '' : 'variant-outline'}`}
|
|
||||||
variant={pkg.popular ? 'default' : 'outline'}
|
|
||||||
>
|
|
||||||
{payLoading === pkg.id ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
|
||||||
{t.packages.buying}
|
|
||||||
</span>
|
|
||||||
) : t.packages.buy}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<p className="text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||||
<p className="text-sm text-muted-foreground">
|
{t.donate.disclaimer}
|
||||||
{t.packages.orUpgrade}{' '}
|
|
||||||
<Link href="/my/member" className="text-brand-600 hover:underline">{t.member.title}</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaymentModal
|
|
||||||
open={paymentModal.open}
|
|
||||||
orderNo={paymentModal.orderNo}
|
|
||||||
payResult={paymentModal.payResult}
|
|
||||||
payChannel={paymentModal.payChannel}
|
|
||||||
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
|
|
||||||
onPaid={handlePaymentPaid}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import PromptsPage from '../app/prompts/page';
|
import PromptsPage from '../page';
|
||||||
|
|
||||||
describe('Prompts Page', () => {
|
describe('Prompts Page', () => {
|
||||||
it('should render prompts page', () => {
|
it('should render prompts page', () => {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'AI 提示词库 - 宇之然 AI',
|
||||||
|
description: '宇之然 AI 提示词库收录 200+ 精选提示词模板,覆盖编程、写作、办公等场景,支持一键复制到沙盒使用。',
|
||||||
|
alternates: { canonical: '/prompts' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PromptsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -778,11 +778,11 @@ function SandboxPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
|
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">{t.sandbox.quotaExhausted}</div>
|
||||||
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.packages.buyMore}</div>
|
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.sandbox.quotaExhaustedHint}</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/practices/packages"
|
<Link href="/donate"
|
||||||
className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
|
className="px-3 py-1.5 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 shrink-0">
|
||||||
{t.packages.title}
|
{t.donate.toDonate}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -790,9 +790,6 @@ function SandboxPage() {
|
|||||||
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-3">
|
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-3">
|
||||||
<span>{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.dailyRemaining))}</span>
|
<span>{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.dailyRemaining))}</span>
|
||||||
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
|
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
|
||||||
{quota.dailyRemaining <= 0 && quota.extra > 0 && (
|
|
||||||
<Link href="/practices/packages" className="text-brand-600 hover:underline text-[10px]">{t.packages.buyMore}</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uploadedImages.length > 0 && (
|
{uploadedImages.length > 0 && (
|
||||||
|
|||||||
@@ -14,20 +14,41 @@ 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({ initialSkill }: { initialSkill?: Skill | null }) {
|
||||||
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>(initialSkill ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialSkill);
|
||||||
|
const [links, setLinks] = useState<AffiliateLink[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialSkill) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
fetch(`${API_BASE}/skills/${params.id}`)
|
fetch(`${API_BASE}/skills/${params.id}`)
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => { if (data.id) setSkill(data); })
|
.then((data) => { if (data.id) setSkill(data); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id, initialSkill]);
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -108,6 +129,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,66 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { API_BASE, SITE_URL } from '@/lib/config';
|
||||||
|
import { articleJsonLd, breadcrumbJsonLd, absoluteUrl } from '@/lib/seo';
|
||||||
import SkillDetailClient from './client';
|
import SkillDetailClient from './client';
|
||||||
|
|
||||||
export function generateStaticParams() {
|
async function getSkill(id: string) {
|
||||||
const skillIds = ['general-chat', 'coding', 'writing', 'study', 'english', 'prompt-engineering', 'data-analysis', 'career'];
|
try {
|
||||||
return skillIds.map(id => ({ id }));
|
const res = await fetch(`${API_BASE}/skills/${id}`, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SkillDetailPage() {
|
export async function generateStaticParams() {
|
||||||
return <SkillDetailClient />;
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/skills?pageSize=200`, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
const list = Array.isArray(data) ? data : data?.items ?? [];
|
||||||
|
return list.map((s: { id: string }) => ({ id: s.id }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||||
|
const skill = await getSkill(params.id);
|
||||||
|
if (!skill) return { title: '技能未找到' };
|
||||||
|
const title = skill.name;
|
||||||
|
const description = skill.description || `${skill.name} - 宇之然 AI 技能,免费学习与实践`;
|
||||||
|
const url = absoluteUrl(`/skills/${skill.id}`);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: { canonical: `/skills/${skill.id}` },
|
||||||
|
openGraph: { type: 'article', title, description, url },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SkillDetailPage({ params }: { params: { id: string } }) {
|
||||||
|
const skill = await getSkill(params.id);
|
||||||
|
const url = absoluteUrl(`/skills/${params.id}`);
|
||||||
|
const jsonLd = skill
|
||||||
|
? [
|
||||||
|
articleJsonLd({ headline: skill.name, description: skill.description || undefined, url }),
|
||||||
|
breadcrumbJsonLd([
|
||||||
|
{ name: '首页', url: SITE_URL },
|
||||||
|
{ name: '技能库', url: absoluteUrl('/skills') },
|
||||||
|
{ name: skill.name, url },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{jsonLd && (
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
)}
|
||||||
|
<SkillDetailClient initialSkill={skill} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'AI 技能库 - 免费学习与实践',
|
||||||
|
description: '宇之然 AI 技能库收录可组合的 AI 学习技能模块,覆盖基础、技术、创意、教育等领域,免费学习并在沙盒中实战练习。',
|
||||||
|
alternates: { canonical: '/skills' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SkillsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { SITE_URL } from '@/lib/config';
|
||||||
|
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: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web',
|
||||||
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: '宇之然 AI',
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: { '@type': 'ImageObject', url: `${SITE_URL}/favicon.png` },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析描述中的结构化字段
|
||||||
|
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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import Header from '../header';
|
import { Header } from '../header';
|
||||||
|
|
||||||
describe('Header Component', () => {
|
describe('Header Component', () => {
|
||||||
it('should render navigation links', () => {
|
it('should render navigation links', () => {
|
||||||
|
|||||||
@@ -75,8 +75,10 @@ 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>
|
||||||
|
<li><Link href="/affiliate" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.donate.affiliate}</Link></li>
|
||||||
|
<li><Link href="/donate" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.donate.supportUs}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -53,35 +53,20 @@ 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: '/affiliate', label: t.nav.affiliate },
|
||||||
|
{ href: '/donate', label: t.nav.donate },
|
||||||
{ 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 +93,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 +107,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 +116,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 +172,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}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Translations } from './zh'
|
|||||||
|
|
||||||
const en: Translations = {
|
const en: Translations = {
|
||||||
common: { loading: 'Loading...', save: 'Save', cancel: 'Cancel', delete: 'Delete', confirm: 'Confirm', search: 'Search', back: 'Back', login: 'Login', register: 'Register', logout: 'Logout', retry: 'Retry', noData: 'No data', viewAll: 'View all' },
|
common: { loading: 'Loading...', save: 'Save', cancel: 'Cancel', delete: 'Delete', confirm: 'Confirm', search: 'Search', back: 'Back', login: 'Login', register: 'Register', logout: 'Logout', retry: 'Retry', noData: 'No data', viewAll: 'View all' },
|
||||||
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles', practices: 'Practice', enterprise: 'Enterprise', marketplace: 'Marketplace', more: 'More' },
|
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles', practices: 'Practice', enterprise: 'Enterprise', marketplace: 'Marketplace', affiliate: 'Affiliate', donate: 'Donate', more: 'More' },
|
||||||
home: { badge: 'AI Tool Guide & Practice', heroHighlight: 'AI Tool Guide', heroRest: 'Practice · Skill Building', desc: 'Curated AI tools with real-world practice to accelerate your AI skills', startExplore: 'Get Started', freeRegister: 'Register Free', statTopics: 'AI Topics', statPrompts: 'Curated Prompts', statTools: 'AI Tool Reviews', statExplorers: 'Explorers', whyTitle: 'Why Yuzhiran?', whyDesc: 'Four core advantages to master AI fast', featureGuide: 'Curated Tools', featureGuideDesc: 'Hand-picked AI tools to help you find the right solution', featureSandbox: 'Practice Sandbox', featureSandboxDesc: 'Practice tool usage skills in the sandbox', featurePrompts: 'Prompt Library', featurePromptsDesc: '200+ curated prompt templates', featureUpdate: 'Always Up-to-date', featureUpdateDesc: 'Content updated as AI evolves', popularTopics: 'Popular Topics', popularDesc: 'From beginner to expert, explore AI systematically', moduleCount: '{n} modules', studentCount: '{n} learners', openSandbox: 'Open Sandbox', featuredToolsTitle: 'Featured Tools', featuredToolsDesc: 'Hand-picked AI tools for you', ctaTitle: 'Ready to Start Your AI Journey?', ctaDesc: 'Register now and explore everything for free' },
|
home: { badge: 'AI Tool Guide & Practice', heroHighlight: 'AI Tool Guide', heroRest: 'Practice · Skill Building', desc: 'Curated AI tools with real-world practice to accelerate your AI skills', startExplore: 'Get Started', freeRegister: 'Register Free', statTopics: 'AI Topics', statPrompts: 'Curated Prompts', statTools: 'AI Tool Reviews', statExplorers: 'Explorers', whyTitle: 'Why Yuzhiran?', whyDesc: 'Four core advantages to master AI fast', featureGuide: 'Curated Tools', featureGuideDesc: 'Hand-picked AI tools to help you find the right solution', featureSandbox: 'Practice Sandbox', featureSandboxDesc: 'Practice tool usage skills in the sandbox', featurePrompts: 'Prompt Library', featurePromptsDesc: '200+ curated prompt templates', featureUpdate: 'Always Up-to-date', featureUpdateDesc: 'Content updated as AI evolves', popularTopics: 'Popular Topics', popularDesc: 'From beginner to expert, explore AI systematically', moduleCount: '{n} modules', studentCount: '{n} learners', openSandbox: 'Open Sandbox', featuredToolsTitle: 'Featured Tools', featuredToolsDesc: 'Hand-picked AI tools for you', ctaTitle: 'Ready to Start Your AI Journey?', ctaDesc: 'Register now and explore everything for free' },
|
||||||
auth: { loginTitle: 'Login', registerTitle: 'Register', phone: 'Phone', password: 'Password', nickname: 'Nickname', username: 'Username', welcomeBack: 'Welcome back', loginSubtitle: 'Log in to continue your AI journey', joinTitle: 'Join Yuzhiran', registerSubtitle: 'Register for free and explore AI', accountPlaceholder: 'Username / Phone / Email', loggingIn: 'Logging in...', nicknameOptional: 'Nickname (optional)', usernameOptional: 'Username (optional, 2-20 chars)', passwordHint: 'Password (min 6 characters)', confirmPassword: 'Confirm password', registering: 'Registering...', agreePrefix: 'By registering, you agree to our', termsOfService: 'Terms of Service', privacyPolicy: 'Privacy Policy', aiAgreement: 'AI Service Agreement', fillAccountAndPassword: 'Please enter account and password', fillPhoneOrEmail: 'Please enter phone or email', fillPassword: 'Please enter password', passwordMinLength: 'Password must be at least 6 characters', passwordsNotMatch: 'Passwords do not match', loginFailed: 'Login failed', registerFailed: 'Registration failed', loginSuccess: 'Login successful', registerSuccess: 'Registration successful', usernameConflict: 'Username already taken', phoneConflict: 'Phone already registered', emailConflict: 'Email already registered' },
|
auth: { loginTitle: 'Login', registerTitle: 'Register', phone: 'Phone', password: 'Password', nickname: 'Nickname', username: 'Username', welcomeBack: 'Welcome back', loginSubtitle: 'Log in to continue your AI journey', joinTitle: 'Join Yuzhiran', registerSubtitle: 'Register for free and explore AI', accountPlaceholder: 'Username / Phone / Email', loggingIn: 'Logging in...', nicknameOptional: 'Nickname (optional)', usernameOptional: 'Username (optional, 2-20 chars)', passwordHint: 'Password (min 6 characters)', confirmPassword: 'Confirm password', registering: 'Registering...', agreePrefix: 'By registering, you agree to our', termsOfService: 'Terms of Service', privacyPolicy: 'Privacy Policy', aiAgreement: 'AI Service Agreement', fillAccountAndPassword: 'Please enter account and password', fillPhoneOrEmail: 'Please enter phone or email', fillPassword: 'Please enter password', passwordMinLength: 'Password must be at least 6 characters', passwordsNotMatch: 'Passwords do not match', loginFailed: 'Login failed', registerFailed: 'Registration failed', loginSuccess: 'Login successful', registerSuccess: 'Registration successful', usernameConflict: 'Username already taken', phoneConflict: 'Phone already registered', emailConflict: 'Email already registered' },
|
||||||
dashboard: { title: 'My Learning', desc: 'Track your learning progress and stats', inProgressCourses: 'Courses in Progress', completedLessons: 'Lessons Completed', favoritePrompts: 'Favorite Prompts', studyDays: 'Study Days', todayLearned: "Today's Learning", tabProgress: 'Progress', tabFavorites: 'Favorites', tabProfile: 'Profile', recentLearning: 'Recent Learning', modelLabel: 'Model: {model}', viewCount: '{n} views', likeCount: '{n} likes', noLearningRecords: 'No learning records yet', browseCourses: 'Browse Courses', learningProgress: 'Learning Progress', lessonCount: '{completed}/{total} lessons ({progress}%)', noFavorites: 'No favorite prompts yet', browsePrompts: 'Browse Prompts', profile: 'Profile', nicknameLabel: 'Nickname', nicknamePlaceholder: 'Enter nickname', memberPlan: 'Membership', freeUser: 'Free User', memberExpire: 'Membership Expires', joinDate: 'Joined', saveSuccess: 'Saved successfully', saveFailed: 'Save failed', loadFailed: 'Failed to load data' },
|
dashboard: { title: 'My Learning', desc: 'Track your learning progress and stats', inProgressCourses: 'Courses in Progress', completedLessons: 'Lessons Completed', favoritePrompts: 'Favorite Prompts', studyDays: 'Study Days', todayLearned: "Today's Learning", tabProgress: 'Progress', tabFavorites: 'Favorites', tabProfile: 'Profile', recentLearning: 'Recent Learning', modelLabel: 'Model: {model}', viewCount: '{n} views', likeCount: '{n} likes', noLearningRecords: 'No learning records yet', browseCourses: 'Browse Courses', learningProgress: 'Learning Progress', lessonCount: '{completed}/{total} lessons ({progress}%)', noFavorites: 'No favorite prompts yet', browsePrompts: 'Browse Prompts', profile: 'Profile', nicknameLabel: 'Nickname', nicknamePlaceholder: 'Enter nickname', memberPlan: 'Membership', freeUser: 'Free User', memberExpire: 'Membership Expires', joinDate: 'Joined', saveSuccess: 'Saved successfully', saveFailed: 'Save failed', loadFailed: 'Failed to load data' },
|
||||||
@@ -15,26 +15,28 @@ 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' },
|
||||||
footer: { tagline: 'Empowering Everyone to Master AI', explore: 'Explore', about: 'About', aboutUs: 'About Us', privacy: 'Privacy Policy', terms: 'Terms of Service', aiAgreement: 'AI Service Agreement', contact: 'Contact', copyright: '© {year} Yuzhiran Technology Center. All rights reserved.', models: 'Capabilities', aiTools: 'AI Tools', articles: 'Articles', skills: 'Skills' },
|
footer: { tagline: 'Empowering Everyone to Master AI', explore: 'Explore', about: 'About', aboutUs: 'About Us', privacy: 'Privacy Policy', terms: 'Terms of Service', aiAgreement: 'AI Service Agreement', contact: 'Contact', copyright: '© {year} Yuzhiran Technology Center. All rights reserved.', models: 'Capabilities', aiTools: 'AI Tools', articles: 'Articles', skills: 'Skills' },
|
||||||
models: { title: 'AI Capabilities Guide', desc: 'Discover what AI can do for you — from conversations to coding, find your perfect use case', capChat: 'Chat & Writing', capChatDesc: 'Chat with AI for ideas, copywriting, translation, and more', capCode: 'Coding Assistant', capCodeDesc: 'Generate code, debug errors, explain algorithms — your coding partner', capData: 'Data Analysis', capDataDesc: 'Analyze data, generate charts, uncover trends — let data speak', capPrompt: 'Prompt Engineering', capPromptDesc: 'Learn to write effective prompts, master AI communication', capPractice: 'Skill Practice', capPracticeDesc: 'Practice AI skills in real scenarios with instant scoring feedback', capMarketplace: 'Workflow Skills', capMarketplaceDesc: 'Buy ready-to-use AI workflow skills for specific business problems', goSandbox: 'Open Sandbox', goPrompts: 'Browse Prompts', goPractice: 'Start Practice', goMarketplace: 'Go to Marketplace' },
|
models: { title: 'AI Capabilities Guide', desc: 'Discover what AI can do for you — from conversations to coding, find your perfect use case', capChat: 'Chat & Writing', capChatDesc: 'Chat with AI for ideas, copywriting, translation, and more', capCode: 'Coding Assistant', capCodeDesc: 'Generate code, debug errors, explain algorithms — your coding partner', capData: 'Data Analysis', capDataDesc: 'Analyze data, generate charts, uncover trends — let data speak', capPrompt: 'Prompt Engineering', capPromptDesc: 'Learn to write effective prompts, master AI communication', capPractice: 'Skill Practice', capPracticeDesc: 'Practice AI skills in real scenarios with instant scoring feedback', capMarketplace: 'Workflow Skills', capMarketplaceDesc: 'Free ready-to-use AI workflow skills for specific business problems', goSandbox: 'Open Sandbox', goPrompts: 'Browse Prompts', goPractice: 'Start Practice', goMarketplace: 'Go to Marketplace' },
|
||||||
error: { title: 'Something went wrong', desc: 'Page failed to load. Please try again.', reload: 'Reload' },
|
error: { title: 'Something went wrong', desc: 'Page failed to load. Please try again.', reload: 'Reload' },
|
||||||
notFound: { title: '404', desc: 'Page not found', backToHome: 'Back to Home' },
|
notFound: { title: '404', desc: 'Page not found', backToHome: 'Back to Home' },
|
||||||
share: { missingToken: 'Missing share token', invalidLink: 'Invalid share link', notAvailable: 'Shared content not available', expired: 'This share link may have expired', goToSandbox: 'Go to AI Sandbox', backToSandbox: 'AI Sandbox', modelInfo: 'Model: {model} · {date}' },
|
share: { missingToken: 'Missing share token', invalidLink: 'Invalid share link', notAvailable: 'Shared content not available', expired: 'This share link may have expired', goToSandbox: 'Go to AI Sandbox', backToSandbox: 'AI Sandbox', modelInfo: 'Model: {model} · {date}' },
|
||||||
path: { back: 'Back', totalProgress: 'Total Progress', taskCount: '{completed}/{total} tasks' },
|
path: { back: 'Back', totalProgress: 'Total Progress', taskCount: '{completed}/{total} tasks' },
|
||||||
sandbox: { title: 'Sandbox', subtitle: 'Experience AI conversations online', placeholder: 'Ask me anything...', send: 'Send', sending: 'Sending', newChat: 'New Chat', history: 'History', searchHistory: 'Search history...', noHistory: 'No history', sceneGeneral: 'General', sceneCoding: 'Coding', sceneWriting: 'Writing', sceneStudy: 'Study', sceneEnglish: 'English', sceneTutor: 'Programming Tutor', modelGeneral: 'General', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: 'Advanced', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: 'Helpful', notHelpful: 'Not Helpful', runInCodeSandbox: 'Run in Code Sandbox', shareToCommunity: 'Share to Community', copyShareLink: 'Copy Link', linkCopied: 'Link copied', shareSuccess: 'Shared successfully', shareFailed: 'Share failed', loginForMore: 'Login for more', dailyQuota: 'Used {used} today, {remaining} remaining', aiReplyDisclaimer: 'AI replies are for reference only.', loginForMoreQuota: 'Login for more daily quota and models.', justNow: 'just now', minutesAgo: '{n}m ago', hoursAgo: '{n}h ago',
|
sandbox: { title: 'Sandbox', subtitle: 'Experience AI conversations online', placeholder: 'Ask me anything...', send: 'Send', sending: 'Sending', newChat: 'New Chat', history: 'History', searchHistory: 'Search history...', noHistory: 'No history', sceneGeneral: 'General', sceneCoding: 'Coding', sceneWriting: 'Writing', sceneStudy: 'Study', sceneEnglish: 'English', sceneTutor: 'Programming Tutor', modelGeneral: 'General', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: 'Advanced', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: 'Helpful', notHelpful: 'Not Helpful', runInCodeSandbox: 'Run in Code Sandbox', shareToCommunity: 'Share to Community', copyShareLink: 'Copy Link', linkCopied: 'Link copied', shareSuccess: 'Shared successfully', shareFailed: 'Share failed', loginForMore: 'Login for more', dailyQuota: 'Used {used} today, {remaining} remaining', aiReplyDisclaimer: 'AI replies are for reference only.', loginForMoreQuota: 'Login for more daily quota and models.', justNow: 'just now', minutesAgo: '{n}m ago', hoursAgo: '{n}h ago',
|
||||||
freeMode: 'Free Mode', learnMode: 'Learning Mode', learnPath: 'Learning Path', learnPathDesc: 'From zero to pro in 6 steps. Click each stage to practice in free mode, check tasks when done to proceed.', stage: 'Step {n}', stageProgress: '{done}/{total} done', startPractice: 'Start Practice', stageDone: 'Completed', stageLocked: 'Locked', stageWelcome: 'First Contact', stageWelcomeDesc: 'Start your first AI conversation', stageScene: 'Scene Practice', stageSceneDesc: 'Practice in different role scenarios', stageParams: 'Parameter Tuning', stageParamsDesc: 'Adjust Temperature and see what changes', stageModels: 'Model Comparison', stageModelsDesc: 'Switch models to compare their styles', stagePrompts: 'Advanced Prompting', stagePromptsDesc: 'Learn role-setting, structured prompts', stageMaster: 'Final Challenge', stageMasterDesc: 'Apply everything in a real-world task', taskSendMsg: 'Send your first message', taskTryStarter: 'Try a starter question', taskReadReply: 'Understand AI reply characteristics', taskSwitchCoding: 'Switch to Coding scene', taskSwitchWriting: 'Switch to Writing scene', taskSwitchStudy: 'Switch to Study scene', taskHighTemp: 'Try Temperature at 0.9', taskLowTemp: 'Try Temperature at 0.1', taskCompareTemp: 'Compare the differences', taskSwitchModel: 'Switch to DeepSeek model', taskCompareModel: 'Compare model reply styles', taskRolePrompt: 'Write a prompt with role-setting', taskStructured: 'Try structured step-by-step prompts', taskCodeExec: 'Run AI-generated code in Code Sandbox', taskCommunity: 'Share a conversation to Community', quotaExhausted: 'Free quota exhausted', upgradeNow: 'Upgrade', quotaUpgradeHint: 'Upgrade for more quota and all models', quotaLoginHint: 'Login for more free daily quota' },
|
freeMode: 'Free Mode', learnMode: 'Learning Mode', learnPath: 'Learning Path', learnPathDesc: 'From zero to pro in 6 steps. Click each stage to practice in free mode, check tasks when done to proceed.', stage: 'Step {n}', stageProgress: '{done}/{total} done', startPractice: 'Start Practice', stageDone: 'Completed', stageLocked: 'Locked', stageWelcome: 'First Contact', stageWelcomeDesc: 'Start your first AI conversation', stageScene: 'Scene Practice', stageSceneDesc: 'Practice in different role scenarios', stageParams: 'Parameter Tuning', stageParamsDesc: 'Adjust Temperature and see what changes', stageModels: 'Model Comparison', stageModelsDesc: 'Switch models to compare their styles', stagePrompts: 'Advanced Prompting', stagePromptsDesc: 'Learn role-setting, structured prompts', stageMaster: 'Final Challenge', stageMasterDesc: 'Apply everything in a real-world task', taskSendMsg: 'Send your first message', taskTryStarter: 'Try a starter question', taskReadReply: 'Understand AI reply characteristics', taskSwitchCoding: 'Switch to Coding scene', taskSwitchWriting: 'Switch to Writing scene', taskSwitchStudy: 'Switch to Study scene', taskHighTemp: 'Try Temperature at 0.9', taskLowTemp: 'Try Temperature at 0.1', taskCompareTemp: 'Compare the differences', taskSwitchModel: 'Switch to DeepSeek model', taskCompareModel: 'Compare model reply styles', taskRolePrompt: 'Write a prompt with role-setting', taskStructured: 'Try structured step-by-step prompts', taskCodeExec: 'Run AI-generated code in Code Sandbox', taskCommunity: 'Share a conversation to Community', quotaExhausted: 'Free quota exhausted', quotaExhaustedHint: 'Free quota exhausted. Donate to support our operation', upgradeNow: 'Upgrade', quotaUpgradeHint: 'Upgrade for more quota and all models', quotaLoginHint: 'Login for more free daily quota' },
|
||||||
learning: { analytics: 'Learning Analytics', analyticsDesc: 'Analyze your learning based on AI conversations', path: 'Learning Path', pathDesc: 'Master AI skills systematically', totalSessions: 'AI Sessions', domainsCovered: 'Domains Covered', avgMastery: 'Avg Mastery', knowledgeDomains: 'Knowledge Domains', weakAreas: 'Weak Areas', weakDesc: 'Consider strengthening these areas:', recommendations: 'Recommendations', recDesc: 'Based on your weak areas', toStrengthen: 'To Strengthen', conversations: '{count} conversations', clickToGo: 'Go' },
|
learning: { analytics: 'Learning Analytics', analyticsDesc: 'Analyze your learning based on AI conversations', path: 'Learning Path', pathDesc: 'Master AI skills systematically', totalSessions: 'AI Sessions', domainsCovered: 'Domains Covered', avgMastery: 'Avg Mastery', knowledgeDomains: 'Knowledge Domains', weakAreas: 'Weak Areas', weakDesc: 'Consider strengthening these areas:', recommendations: 'Recommendations', recDesc: 'Based on your weak areas', toStrengthen: 'To Strengthen', conversations: '{count} conversations', clickToGo: 'Go' },
|
||||||
member: { title: 'Membership', desc: 'Manage your subscription', currentPlan: 'Current Plan', freeUser: 'You are on the Free plan', monthly: 'Monthly', yearly: 'Yearly', monthlyPrice: '¥49.9/month', yearlyPrice: '¥299/year', expires: 'Expires: {date}', benefits: 'All courses + unlimited sandbox + premium prompts + ad-free', orderHistory: 'Order History', noOrders: 'No orders yet', processing: 'Processing...', planFree: 'Free', planMonthly: 'Monthly', planYearly: 'Yearly', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/mo', perYear: '/yr', popular: 'Popular', featureSandbox: 'AI Sandbox', featureSandboxFree: '10/day', featureSandboxPro: '100/day', featureSandboxUnlimited: 'Unlimited', featureModels: 'Models', featureModelsFree: '1 model', featureModelsPro: '2 models', featureModelsPremium: 'All models', featurePrompts: 'Prompts', featurePromptsFree: 'Basic', featurePromptsPro: 'All', featurePromptsPremium: 'All + Exclusive', featureCourses: 'Courses', featureCoursesFree: 'Partial', featureCoursesPro: 'All', featureCoursesPremium: 'All', featureAds: 'Ads', featureAdsFree: 'Ads', featureAdsPro: 'Ad-free', featureAdsPremium: 'Ad-free', dailyQuota: 'Daily Quota', used: '{n} used', subscribe: 'Subscribe', currentPlan_badge: 'Current', payMethod: 'Payment Method', wechatPay: 'WeChat Pay', alipay: 'Alipay', scanQrCode: 'Scan QR code with WeChat', alipayRedirect: 'Redirecting to Alipay...', openAlipay: 'Open Alipay', payFailed: 'Payment failed, please retry', orderPaid: 'Payment successful!', cancelPay: 'Cancel', waitingPay: 'Waiting for payment...' },
|
member: { title: 'Membership', desc: 'Manage your subscription', currentPlan: 'Current Plan', freeUser: 'You are on the Free plan', monthly: 'Monthly', yearly: 'Yearly', monthlyPrice: '¥49.9/month', yearlyPrice: '¥299/year', expires: 'Expires: {date}', benefits: 'All courses + unlimited sandbox + premium prompts + ad-free', orderHistory: 'Order History', noOrders: 'No orders yet', processing: 'Processing...', planFree: 'Free', planMonthly: 'Monthly', planYearly: 'Yearly', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/mo', perYear: '/yr', popular: 'Popular', featureSandbox: 'AI Sandbox', featureSandboxFree: '10/day', featureSandboxPro: '100/day', featureSandboxUnlimited: 'Unlimited', featureModels: 'Models', featureModelsFree: '1 model', featureModelsPro: '2 models', featureModelsPremium: 'All models', featurePrompts: 'Prompts', featurePromptsFree: 'Basic', featurePromptsPro: 'All', featurePromptsPremium: 'All + Exclusive', featureCourses: 'Courses', featureCoursesFree: 'Partial', featureCoursesPro: 'All', featureCoursesPremium: 'All', featureAds: 'Ads', featureAdsFree: 'Ads', featureAdsPro: 'Ad-free', featureAdsPremium: 'Ad-free', dailyQuota: 'Daily Quota', used: '{n} used', subscribe: 'Subscribe', currentPlan_badge: 'Current', payMethod: 'Payment Method', wechatPay: 'WeChat Pay', alipay: 'Alipay', scanQrCode: 'Scan QR code with WeChat', alipayRedirect: 'Redirecting to Alipay...', openAlipay: 'Open Alipay', payFailed: 'Payment failed, please retry', orderPaid: 'Payment successful!', cancelPay: 'Cancel', waitingPay: 'Waiting for payment...' },
|
||||||
compare: { title: 'Compare Lab', desc: 'Compare how different models respond', placeholder: 'Enter a question or prompt to compare...', startCompare: 'Start Compare', comparing: 'Comparing...', backToSandbox: 'Back to Sandbox', noResponse: 'No response' },
|
compare: { title: 'Compare Lab', desc: 'Compare how different models respond', placeholder: 'Enter a question or prompt to compare...', startCompare: 'Start Compare', comparing: 'Comparing...', backToSandbox: 'Back to Sandbox', noResponse: 'No response' },
|
||||||
codeSandbox: { title: 'Code Sandbox', run: 'Run', runShortcut: 'Run (⌘⏎)', template: 'Template...', blank: 'Blank', react: 'React (CDN)', chart: 'Chart (Chart.js)', three: '3D (Three.js)', console: 'Console' },
|
codeSandbox: { title: 'Code Sandbox', run: 'Run', runShortcut: 'Run (⌘⏎)', template: 'Template...', blank: 'Blank', react: 'React (CDN)', chart: 'Chart (Chart.js)', three: '3D (Three.js)', console: 'Console' },
|
||||||
skills: { title: 'Skills', desc: 'Composable AI learning skill modules', search: 'Search skills...', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', tasks: 'Practice Tasks', starters: 'Try These', prerequisites: 'Prerequisites', apply: 'Use This Skill', categories: { basic: 'Basic', technical: 'Technical', creative: 'Creative', education: 'Education', advanced: 'Advanced', career: 'Career', marketing: 'Marketing', business: 'Business' } },
|
skills: { title: 'Skills', desc: 'Composable AI learning skill modules', search: 'Search skills...', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', tasks: 'Practice Tasks', starters: 'Try These', prerequisites: 'Prerequisites', apply: 'Use This Skill', categories: { basic: 'Basic', technical: 'Technical', creative: 'Creative', education: 'Education', advanced: 'Advanced', career: 'Career', marketing: 'Marketing', business: 'Business' } },
|
||||||
marketplace: { title: 'Skill Marketplace', desc: 'One-time purchase, lifetime access — premium AI skills for your workflow', free: 'Free', buy: 'Unlock · ¥{price}', buyNow: 'Buy Now', buying: 'Processing...', purchased: 'Owned', locked: 'Premium', loginRequired: 'Login to purchase', purchaseSuccess: 'Purchased! You can now use this skill', unlockToUse: 'Unlock to use', tryForFree: 'Try Free Skills', orUpgrade: 'Browse more premium skills', skillMarketplace: 'Skill Marketplace' },
|
marketplace: { title: 'Skill Marketplace', desc: 'An open library of AI skills — premium workflows for your use, free of charge', free: 'Free', freeNote: 'All skills are free. Your support keeps the platform updated', buy: 'Unlock · ¥{price}', buyNow: 'Buy Now', buying: 'Processing...', purchased: 'Owned', locked: 'Premium', loginRequired: 'Login to purchase', purchaseSuccess: 'Purchased! You can now use this skill', unlockToUse: 'Unlock to use', tryForFree: 'Try Free Skills', orUpgrade: 'Browse more premium skills', skillMarketplace: 'Skill Marketplace' },
|
||||||
promptWorkshop: { title: 'Prompt Workshop', desc: 'Write, test, and optimize your prompts', editor: 'Prompt Editor', test: 'Test Prompt', testing: 'Testing...', clear: 'Clear', saveToLibrary: 'Save to Library', saveSuccess: 'Saved successfully!', variables: 'Variables', role: 'Role', task: 'Task', outputFormat: 'Output Format', constraints: 'Constraints', insert: 'Insert', testResult: 'Test Result', saveDialogTitle: 'Save Prompt', saveTitle: 'Title *', saveDesc: 'Description', saveTags: 'Tags', saveTagsPlaceholder: 'e.g. programming,Python,debug', saving: 'Saving...' },
|
promptWorkshop: { title: 'Prompt Workshop', desc: 'Write, test, and optimize your prompts', editor: 'Prompt Editor', test: 'Test Prompt', testing: 'Testing...', clear: 'Clear', saveToLibrary: 'Save to Library', saveSuccess: 'Saved successfully!', variables: 'Variables', role: 'Role', task: 'Task', outputFormat: 'Output Format', constraints: 'Constraints', insert: 'Insert', testResult: 'Test Result', saveDialogTitle: 'Save Prompt', saveTitle: 'Title *', saveDesc: 'Description', saveTags: 'Tags', saveTagsPlaceholder: 'e.g. programming,Python,debug', saving: 'Saving...' },
|
||||||
practices: { title: 'AI Practice', desc: 'Practice AI skills with real-world scenarios and get instant scoring feedback', submit: 'Submit Answer', submitting: 'Scoring...', score: 'Score', feedback: 'Feedback', criteria: 'Criteria', duration: 'Duration', seconds: 's', startPractice: 'Start Practice', yourAnswer: 'Your Answer', answerPlaceholder: 'Enter your answer here...', hint: 'Hint', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', categories: { general: 'General', coding: 'Coding', writing: 'Writing', analysis: 'Data Analysis', customer: 'Customer Service', creative: 'Creative' }, loginRequired: 'Login to submit practice', alreadySubmitted: 'View Score', viewSubmission: 'View Score', submissions: 'My Submissions', noSubmissions: 'No submissions yet', goPractice: 'Go Practice', scoreRange: '{score}/{max}', retry: 'Try Again', status: { SUBMITTED: 'Pending', SCORED: 'Scored' } },
|
practices: { title: 'AI Practice', desc: 'Practice AI skills with real-world scenarios and get instant scoring feedback', submit: 'Submit Answer', submitting: 'Scoring...', score: 'Score', feedback: 'Feedback', criteria: 'Criteria', duration: 'Duration', seconds: 's', startPractice: 'Start Practice', yourAnswer: 'Your Answer', answerPlaceholder: 'Enter your answer here...', hint: 'Hint', allCategories: 'All Categories', allDifficulties: 'All Levels', beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced', categories: { general: 'General', coding: 'Coding', writing: 'Writing', analysis: 'Data Analysis', customer: 'Customer Service', creative: 'Creative' }, loginRequired: 'Login to submit practice', alreadySubmitted: 'View Score', viewSubmission: 'View Score', submissions: 'My Submissions', noSubmissions: 'No submissions yet', goPractice: 'Go Practice', scoreRange: '{score}/{max}', retry: 'Try Again', status: { SUBMITTED: 'Pending', SCORED: 'Scored' } },
|
||||||
packages: { title: 'Quota Packs', desc: 'Purchase extra AI sandbox usage', buy: 'Buy Now', popular: 'Popular', save: 'Save {amount}', quota: '{n} uses', price: '¥{price}', buying: 'Buying...', package10: '10-Use Pack', package10Desc: 'For occasional use', package50: '50-Use Standard Pack', package50Desc: '¥0.20/use, best value', package300: '300-Use Premium Pack', package300Desc: '¥0.17/use, for heavy users', currentQuota: 'Current Quota', dailyQuota: 'Used {used}/{limit} today', extraQuota: 'Extra uses remaining: {n}', quotaExhausted: 'Free quota exhausted', buyMore: 'Buy More', purchaseSuccess: 'Purchase successful! Added {n} uses', purchaseFailed: 'Purchase failed, please retry', orUpgrade: 'Or upgrade to membership for unlimited use' },
|
packages: { title: 'Quota Packs', desc: 'Purchase extra AI sandbox usage', buy: 'Buy Now', popular: 'Popular', save: 'Save {amount}', quota: '{n} uses', price: '¥{price}', buying: 'Buying...', package10: '10-Use Pack', package10Desc: 'For occasional use', package50: '50-Use Standard Pack', package50Desc: '¥0.20/use, best value', package300: '300-Use Premium Pack', package300Desc: '¥0.17/use, for heavy users', currentQuota: 'Current Quota', dailyQuota: 'Used {used}/{limit} today', extraQuota: 'Extra uses remaining: {n}', quotaExhausted: 'Free quota exhausted', buyMore: 'Buy More', purchaseSuccess: 'Purchase successful! Added {n} uses', purchaseFailed: 'Purchase failed, please retry', orUpgrade: 'Or upgrade to membership for unlimited use' },
|
||||||
|
donate: { title: 'Donate', desc: 'Yuzhiran stays free and open. Your support keeps us updating.', supportTitle: 'Buy us a coffee to keep us going', supportDesc: 'All skills, practices and tool guides are free. If they help you, feel free to leave a voluntary tip — any amount is welcome.', qrNote: 'Scan the personal QR code below to tip voluntarily', alipay: 'Alipay', wechat: 'WeChat', showQr: 'Show QR', hideQr: 'Hide', noQr: 'QR codes are being set up. You can also leave a message below.', thanksWall: 'Wall of Thanks', thanksWallDesc: 'Thanks to these friends for their generous support', leaveMessage: 'Leave your support', namePlaceholder: 'Nickname (optional)', messagePlaceholder: 'Say something (optional)', channel: 'Channel', submit: 'Send Thanks', submitting: 'Sending...', submitSuccess: 'Thank you for your support!', submitFailed: 'Failed to submit, please retry', says: 'says', anonymous: 'Anonymous friend', disclaimer: 'Tips are voluntary gifts. The platform provides no goods or services in return and charges no operating fees.', via: 'via {channel}', toDonate: 'Donate', supportUs: 'Support Us', affiliate: 'Affiliate' },
|
||||||
enterprise: { title: 'Enterprise', nav: 'Enterprise', desc: 'Secure, controlled AI training for your team', heroTitle: 'Let Your Team Use AI Safely', heroDesc: '75% of employees use AI tools without IT approval. Enterprise edition provides data-isolated AI sandbox environment for your team to learn, practice, and apply AI securely.', heroCta: 'Start Free Trial', heroCtaSub: 'No credit card required', problemTitle: 'Shadow AI Is Threatening Your Data Security', problemDesc: 'More employees are using public AI tools at work, putting company data at unprecedented risk.', statShadowAi: '75%', statShadowAiDesc: 'Employees use unauthorized AI tools', statDataLeak: '48%', statDataLeakDesc: 'Employees paste company data into public AI', statIpLeak: '43%', statIpLeakDesc: 'Companies experienced IP leakage', statTrainingGap: '68%', statTrainingGapDesc: 'Teachers received no AI training', solutionTitle: 'Enterprise Solutions', solutionDesc: 'Data-isolated AI training environment for teams', featureIsolation: 'Data Isolation', featureIsolationDesc: 'All conversations stored in dedicated instance, never used for training, never leaked to public', featureManage: 'Team Management', featureManageDesc: 'Invite members with one click, manage permissions, view team usage', featureAnalytics: 'Learning Analytics', featureAnalyticsDesc: 'Track team AI skill mastery, identify weak areas, improve targeted', featureSandbox: 'Private Sandbox', featureSandboxDesc: 'Practice in secure isolated AI sandbox, supports major models', featureReport: 'Usage Reports', featureReportDesc: 'Auto-generated team AI usage reports with analytics and recommendations', featureCustom: 'Custom Training', featureCustomDesc: 'Customize practice content for your business scenarios', pricingTitle: 'Flexible Pricing', pricingDesc: 'Choose the right plan for your team', pricingFree: 'Free', pricingFreePrice: '¥0', pricingFreeDesc: 'Personal AI learning experience', pricingFreeFeature1: '10 sandbox uses/day', pricingFreeFeature2: '1 model', pricingFreeFeature3: 'Basic exercises', pricingBiz: 'Enterprise', pricingBizPrice: '¥199', pricingBizPerUser: '/user/year', pricingBizDesc: 'Team AI skill development', pricingBizFeature1: 'Unlimited sandbox', pricingBizFeature2: 'All models', pricingBizFeature3: 'Data isolation', pricingBizFeature4: 'Team management', pricingBizFeature5: 'Analytics reports', pricingBizFeature6: 'Dedicated support', pricingCta: 'Contact Sales', pricingCtaFree: 'Start Free', faqTitle: 'FAQ', faq1q: 'What\'s the difference between Enterprise and Free?', faq1a: 'Enterprise provides data-isolated AI environment, team management dashboard, learning analytics reports, and priority support. All conversation data is isolated from model training.', faq2q: 'How do I invite team members?', faq2a: 'After creating your organization, invite members by email or user ID. Members get access to your enterprise sandbox and learning resources.', faq3q: 'How is data security ensured?', faq3a: 'Enterprise data is stored in isolated database instances, separate from public version. AI conversation data is never used for model training.', faq4q: 'Which AI models are supported?', faq4a: 'Enterprise supports all major AI models including GPT-4, Claude, DeepSeek, and more. Team members can freely switch between models.', faq5q: 'Can practice content be customized?', faq5a: 'Yes. Enterprise supports custom practice questions and scoring criteria based on your business scenarios.', dashboard: 'Dashboard', myTeam: 'My Team', createOrg: 'Create Team', orgName: 'Team Name', orgDesc: 'Team Description', members: 'Members', memberCount: '{n} members', inviteMember: 'Invite Member', inviteById: 'Invite by User ID', memberId: 'User ID', invite: 'Invite', removeMember: 'Remove', removeConfirm: 'Remove this member?', usageReport: 'Usage Report', totalMembers: 'Total Members', totalUsage: 'Total Usage', completionRate: 'Completion Rate', noOrg: 'No team yet', createOrgHint: 'Create a team to manage members and AI learning', noMembers: 'No members yet', inviteHint: 'Invite members to join your team', learnMore: 'Learn More', selectOrg: 'Select a team to view details' },
|
enterprise: { title: 'Enterprise', nav: 'Enterprise', desc: 'Secure, controlled AI training for your team', heroTitle: 'Let Your Team Use AI Safely', heroDesc: '75% of employees use AI tools without IT approval. Enterprise edition provides data-isolated AI sandbox environment for your team to learn, practice, and apply AI securely.', heroCta: 'Start Free Trial', heroCtaSub: 'No credit card required', problemTitle: 'Shadow AI Is Threatening Your Data Security', problemDesc: 'More employees are using public AI tools at work, putting company data at unprecedented risk.', statShadowAi: '75%', statShadowAiDesc: 'Employees use unauthorized AI tools', statDataLeak: '48%', statDataLeakDesc: 'Employees paste company data into public AI', statIpLeak: '43%', statIpLeakDesc: 'Companies experienced IP leakage', statTrainingGap: '68%', statTrainingGapDesc: 'Teachers received no AI training', solutionTitle: 'Enterprise Solutions', solutionDesc: 'Data-isolated AI training environment for teams', featureIsolation: 'Data Isolation', featureIsolationDesc: 'All conversations stored in dedicated instance, never used for training, never leaked to public', featureManage: 'Team Management', featureManageDesc: 'Invite members with one click, manage permissions, view team usage', featureAnalytics: 'Learning Analytics', featureAnalyticsDesc: 'Track team AI skill mastery, identify weak areas, improve targeted', featureSandbox: 'Private Sandbox', featureSandboxDesc: 'Practice in secure isolated AI sandbox, supports major models', featureReport: 'Usage Reports', featureReportDesc: 'Auto-generated team AI usage reports with analytics and recommendations', featureCustom: 'Custom Training', featureCustomDesc: 'Customize practice content for your business scenarios', pricingTitle: 'Flexible Pricing', pricingDesc: 'Choose the right plan for your team', pricingFree: 'Free', pricingFreePrice: '¥0', pricingFreeDesc: 'Personal AI learning experience', pricingFreeFeature1: '10 sandbox uses/day', pricingFreeFeature2: '1 model', pricingFreeFeature3: 'Basic exercises', pricingBiz: 'Enterprise', pricingBizPrice: '¥199', pricingBizPerUser: '/user/year', pricingBizDesc: 'Team AI skill development', pricingBizFeature1: 'Unlimited sandbox', pricingBizFeature2: 'All models', pricingBizFeature3: 'Data isolation', pricingBizFeature4: 'Team management', pricingBizFeature5: 'Analytics reports', pricingBizFeature6: 'Dedicated support', pricingCta: 'Contact Sales', pricingCtaFree: 'Start Free', faqTitle: 'FAQ', faq1q: 'What\'s the difference between Enterprise and Free?', faq1a: 'Enterprise provides data-isolated AI environment, team management dashboard, learning analytics reports, and priority support. All conversation data is isolated from model training.', faq2q: 'How do I invite team members?', faq2a: 'After creating your organization, invite members by email or user ID. Members get access to your enterprise sandbox and learning resources.', faq3q: 'How is data security ensured?', faq3a: 'Enterprise data is stored in isolated database instances, separate from public version. AI conversation data is never used for model training.', faq4q: 'Which AI models are supported?', faq4a: 'Enterprise supports all major AI models including GPT-4, Claude, DeepSeek, and more. Team members can freely switch between models.', faq5q: 'Can practice content be customized?', faq5a: 'Yes. Enterprise supports custom practice questions and scoring criteria based on your business scenarios.', dashboard: 'Dashboard', myTeam: 'My Team', createOrg: 'Create Team', orgName: 'Team Name', orgDesc: 'Team Description', members: 'Members', memberCount: '{n} members', inviteMember: 'Invite Member', inviteById: 'Invite by User ID', memberId: 'User ID', invite: 'Invite', removeMember: 'Remove', removeConfirm: 'Remove this member?', usageReport: 'Usage Report', totalMembers: 'Total Members', totalUsage: 'Total Usage', completionRate: 'Completion Rate', noOrg: 'No team yet', createOrgHint: 'Create a team to manage members and AI learning', noMembers: 'No members yet', inviteHint: 'Invite members to join your team', learnMore: 'Learn More', selectOrg: 'Select a team to view details' },
|
||||||
assistant: { title: 'AI Assistant', greeting: 'Hi! I can help you explore and use this site. Try asking:', placeholder: 'Ask me anything...', error: 'Error: {message}', loginPrompt: '📝 Log in to unlock the full AI experience.\n\nClick "Login" or "Register" in the top right corner.' },
|
assistant: { title: 'AI Assistant', greeting: 'Hi! I can help you explore and use this site. Try asking:', placeholder: 'Ask me anything...', error: 'Error: {message}', loginPrompt: '📝 Log in to unlock the full AI experience.\n\nClick "Login" or "Register" in the top right corner.' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const zh = {
|
const zh = {
|
||||||
common: { loading: '加载中...', save: '保存', cancel: '取消', delete: '删除', confirm: '确认', search: '搜索', back: '返回', login: '登录', register: '注册', logout: '退出登录', retry: '重试', noData: '暂无数据', viewAll: '查看全部' },
|
common: { loading: '加载中...', save: '保存', cancel: '取消', delete: '删除', confirm: '确认', search: '搜索', back: '返回', login: '登录', register: '注册', logout: '退出登录', retry: '重试', noData: '暂无数据', viewAll: '查看全部' },
|
||||||
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章', practices: '练习', enterprise: '企业版', marketplace: '技能广场', more: '更多' },
|
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章', practices: '练习', enterprise: '企业版', marketplace: '技能广场', affiliate: '联盟返佣', donate: '赞助', more: '更多' },
|
||||||
home: { badge: 'AI 工具指南与实战练习', heroHighlight: 'AI 工具指南', heroRest: '实战练习 · 技能提升', desc: '收录优质 AI 工具,提供真实场景练习,助你快速掌握 AI 技能', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: 'AI 工具精选', featureGuideDesc: '精心收录优质 AI 工具,帮你快速找到合适的工具', featureSandbox: 'AI 沙盒练习', featureSandboxDesc: '在沙盒中实践 AI 工具使用技巧', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', featuredToolsTitle: '精选 AI 工具', featuredToolsDesc: '精心挑选的优质 AI 工具推荐', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
|
home: { badge: 'AI 工具指南与实战练习', heroHighlight: 'AI 工具指南', heroRest: '实战练习 · 技能提升', desc: '收录优质 AI 工具,提供真实场景练习,助你快速掌握 AI 技能', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: 'AI 工具精选', featureGuideDesc: '精心收录优质 AI 工具,帮你快速找到合适的工具', featureSandbox: 'AI 沙盒练习', featureSandboxDesc: '在沙盒中实践 AI 工具使用技巧', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', featuredToolsTitle: '精选 AI 工具', featuredToolsDesc: '精心挑选的优质 AI 工具推荐', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
|
||||||
auth: { loginTitle: '登录', registerTitle: '注册', phone: '手机号', password: '密码', nickname: '昵称', username: '用户名', welcomeBack: '欢迎回来', loginSubtitle: '登录继续你的 AI 探索之旅', joinTitle: '加入宇之然', registerSubtitle: '免费注册,开始探索 AI', accountPlaceholder: '用户名 / 手机号 / 邮箱', loggingIn: '登录中...', nicknameOptional: '昵称(选填)', usernameOptional: '用户名(选填,2-20位)', passwordHint: '密码(至少 6 位)', confirmPassword: '确认密码', registering: '注册中...', agreePrefix: '注册即表示同意', termsOfService: '服务协议', privacyPolicy: '隐私政策', aiAgreement: 'AI 服务协议', fillAccountAndPassword: '请填写账号和密码', fillPhoneOrEmail: '请填写手机号或邮箱', fillPassword: '请填写密码', passwordMinLength: '密码至少 6 位', passwordsNotMatch: '两次密码不一致', loginFailed: '登录失败', registerFailed: '注册失败', loginSuccess: '登录成功', registerSuccess: '注册成功', usernameConflict: '用户名已被注册', phoneConflict: '手机号已被注册', emailConflict: '邮箱已被注册' },
|
auth: { loginTitle: '登录', registerTitle: '注册', phone: '手机号', password: '密码', nickname: '昵称', username: '用户名', welcomeBack: '欢迎回来', loginSubtitle: '登录继续你的 AI 探索之旅', joinTitle: '加入宇之然', registerSubtitle: '免费注册,开始探索 AI', accountPlaceholder: '用户名 / 手机号 / 邮箱', loggingIn: '登录中...', nicknameOptional: '昵称(选填)', usernameOptional: '用户名(选填,2-20位)', passwordHint: '密码(至少 6 位)', confirmPassword: '确认密码', registering: '注册中...', agreePrefix: '注册即表示同意', termsOfService: '服务协议', privacyPolicy: '隐私政策', aiAgreement: 'AI 服务协议', fillAccountAndPassword: '请填写账号和密码', fillPhoneOrEmail: '请填写手机号或邮箱', fillPassword: '请填写密码', passwordMinLength: '密码至少 6 位', passwordsNotMatch: '两次密码不一致', loginFailed: '登录失败', registerFailed: '注册失败', loginSuccess: '登录成功', registerSuccess: '注册成功', usernameConflict: '用户名已被注册', phoneConflict: '手机号已被注册', emailConflict: '邮箱已被注册' },
|
||||||
dashboard: { title: '我的学习', desc: '掌握你的学习进度和统计', inProgressCourses: '学习中课程', completedLessons: '已完成课时', favoritePrompts: '收藏提示词', studyDays: '学习天数', todayLearned: '今日学习', tabProgress: '学习进度', tabFavorites: '收藏夹', tabProfile: '个人设置', recentLearning: '最近学习', modelLabel: '模型: {model}', viewCount: '{n} 次浏览', likeCount: '{n} 个赞', noLearningRecords: '还没有学习记录', browseCourses: '浏览课程', learningProgress: '学习进度', lessonCount: '{completed}/{total} 课时 ({progress}%)', noFavorites: '还没有收藏的提示词', browsePrompts: '浏览提示词', profile: '个人资料', nicknameLabel: '昵称', nicknamePlaceholder: '输入昵称', memberPlan: '会员计划', freeUser: '免费用户', memberExpire: '会员到期', joinDate: '注册时间', saveSuccess: '保存成功', saveFailed: '保存失败', loadFailed: '加载数据失败' },
|
dashboard: { title: '我的学习', desc: '掌握你的学习进度和统计', inProgressCourses: '学习中课程', completedLessons: '已完成课时', favoritePrompts: '收藏提示词', studyDays: '学习天数', todayLearned: '今日学习', tabProgress: '学习进度', tabFavorites: '收藏夹', tabProfile: '个人设置', recentLearning: '最近学习', modelLabel: '模型: {model}', viewCount: '{n} 次浏览', likeCount: '{n} 个赞', noLearningRecords: '还没有学习记录', browseCourses: '浏览课程', learningProgress: '学习进度', lessonCount: '{completed}/{total} 课时 ({progress}%)', noFavorites: '还没有收藏的提示词', browsePrompts: '浏览提示词', profile: '个人资料', nicknameLabel: '昵称', nicknamePlaceholder: '输入昵称', memberPlan: '会员计划', freeUser: '免费用户', memberExpire: '会员到期', joinDate: '注册时间', saveSuccess: '保存成功', saveFailed: '保存失败', loadFailed: '加载数据失败' },
|
||||||
@@ -13,26 +13,28 @@ 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' },
|
||||||
footer: { tagline: '让每个人都能用好 AI', explore: '探索', about: '关于', aboutUs: '关于我们', privacy: '隐私政策', terms: '服务协议', aiAgreement: 'AI 服务协议', contact: '联系方式', copyright: '© {year} 北京宇之然科技中心 版权所有', models: '能力导览', aiTools: 'AI 工具', articles: '文章', skills: '技能' },
|
footer: { tagline: '让每个人都能用好 AI', explore: '探索', about: '关于', aboutUs: '关于我们', privacy: '隐私政策', terms: '服务协议', aiAgreement: 'AI 服务协议', contact: '联系方式', copyright: '© {year} 北京宇之然科技中心 版权所有', models: '能力导览', aiTools: 'AI 工具', articles: '文章', skills: '技能' },
|
||||||
models: { title: 'AI 能力导览', desc: '探索 AI 能为你做什么,从对话写作到编程实战,找到适合你的场景', capChat: '对话写作', capChatDesc: '与 AI 进行自然对话,获取灵感、润色文案、翻译语言', capCode: '编程辅助', capCodeDesc: '生成代码、调试错误、解释算法,你的随身编程搭档', capData: '数据分析', capDataDesc: '分析数据、生成图表、洞察趋势,让数据说话', capPrompt: '提示词工程', capPromptDesc: '学习编写高效提示词,掌握与 AI 沟通的最佳实践', capPractice: '技能实战', capPracticeDesc: '在真实场景中练习 AI 技能,获得即时评分反馈', capMarketplace: '工作流技能', capMarketplaceDesc: '购买即用的 AI 工作流技能,解决具体业务问题', goSandbox: '打开沙盒', goPrompts: '浏览提示词库', goPractice: '开始练习', goMarketplace: '前往技能广场' },
|
models: { title: 'AI 能力导览', desc: '探索 AI 能为你做什么,从对话写作到编程实战,找到适合你的场景', capChat: '对话写作', capChatDesc: '与 AI 进行自然对话,获取灵感、润色文案、翻译语言', capCode: '编程辅助', capCodeDesc: '生成代码、调试错误、解释算法,你的随身编程搭档', capData: '数据分析', capDataDesc: '分析数据、生成图表、洞察趋势,让数据说话', capPrompt: '提示词工程', capPromptDesc: '学习编写高效提示词,掌握与 AI 沟通的最佳实践', capPractice: '技能实战', capPracticeDesc: '在真实场景中练习 AI 技能,获得即时评分反馈', capMarketplace: '工作流技能', capMarketplaceDesc: '免费使用的 AI 工作流技能,解决具体业务问题', goSandbox: '打开沙盒', goPrompts: '浏览提示词库', goPractice: '开始练习', goMarketplace: '前往技能广场' },
|
||||||
error: { title: '出错了', desc: '页面加载失败,请稍后重试', reload: '重新加载' },
|
error: { title: '出错了', desc: '页面加载失败,请稍后重试', reload: '重新加载' },
|
||||||
notFound: { title: '404', desc: '页面未找到', backToHome: '返回首页' },
|
notFound: { title: '404', desc: '页面未找到', backToHome: '返回首页' },
|
||||||
share: { missingToken: '缺少分享参数', invalidLink: '分享链接无效', notAvailable: '分享内容不可用', expired: '该分享链接可能已过期或不存在', goToSandbox: '前往 AI 沙盒', backToSandbox: 'AI 沙盒', modelInfo: '模型: {model} · {date}' },
|
share: { missingToken: '缺少分享参数', invalidLink: '分享链接无效', notAvailable: '分享内容不可用', expired: '该分享链接可能已过期或不存在', goToSandbox: '前往 AI 沙盒', backToSandbox: 'AI 沙盒', modelInfo: '模型: {model} · {date}' },
|
||||||
path: { back: '返回我的', totalProgress: '总进度', taskCount: '{completed}/{total} 任务' },
|
path: { back: '返回我的', totalProgress: '总进度', taskCount: '{completed}/{total} 任务' },
|
||||||
sandbox: { title: '沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', sceneTutor: '编程导师', modelGeneral: '通用模式', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: '高级参数', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: '有用', notHelpful: '没用', runInCodeSandbox: '在代码沙盒中运行', shareToCommunity: '分享到社区', copyShareLink: '复制分享链接', linkCopied: '链接已复制', shareSuccess: '分享成功', shareFailed: '分享失败', loginForMore: '登录使用更多', dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次', aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。', loginForMoreQuota: '登录后可获得更多使用次数和更多模型选择。', justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
|
sandbox: { title: '沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', sceneTutor: '编程导师', modelGeneral: '通用模式', modelDeepSeek: 'DeepSeek V4 Flash', advancedParams: '高级参数', temperature: 'Temperature', topP: 'Top P', maxTokens: 'Max Tokens', helpful: '有用', notHelpful: '没用', runInCodeSandbox: '在代码沙盒中运行', shareToCommunity: '分享到社区', copyShareLink: '复制分享链接', linkCopied: '链接已复制', shareSuccess: '分享成功', shareFailed: '分享失败', loginForMore: '登录使用更多', dailyQuota: '今日已用 {used} 次,剩余 {remaining} 次', aiReplyDisclaimer: 'AI 回复由人工智能生成,仅供参考。', loginForMoreQuota: '登录后可获得更多使用次数和更多模型选择。', justNow: '刚刚', minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前',
|
||||||
freeMode: '自由模式', learnMode: '学习模式', learnPath: '学习路径', learnPathDesc: '从零到精通,6 步掌握 AI 对话。点击每项文字进入自由模式练习,学会后打钩确认进入下一项', stage: '第 {n} 步', stageProgress: '{done}/{total} 已完成', startPractice: '开始练习', stageDone: '已完成', stageLocked: '未解锁', stageWelcome: 'AI 初体验', stageWelcomeDesc: '了解 AI 能做什么,发起第一次对话', stageScene: '场景实战', stageSceneDesc: '在不同角色场景中练习对话技巧', stageParams: '参数调优', stageParamsDesc: '调节 Temperature 等参数,观察回复变化', stageModels: '模型对比', stageModelsDesc: '切换不同模型,了解各自特点与差异', stagePrompts: '提示词进阶', stagePromptsDesc: '学习角色设定、结构化提示等高级技巧', stageMaster: '综合实战', stageMasterDesc: '综合运用所学,完成一个完整的实战任务', taskSendMsg: '发送第一条消息', taskTryStarter: '尝试一个 Starter 问题', taskReadReply: '理解 AI 回复的特点', taskSwitchCoding: '切换到编程助手场景', taskSwitchWriting: '切换到写作助手场景', taskSwitchStudy: '切换到学习辅导场景', taskHighTemp: '调高 Temperature 到 0.9 试试', taskLowTemp: '调低 Temperature 到 0.1 对比', taskCompareTemp: '对比两次回复的差异', taskSwitchModel: '切换到 DeepSeek 模型', taskCompareModel: '对比不同模型的回复风格', taskRolePrompt: '使用角色设定写一条提示词', taskStructured: '使用结构化提示(步骤化)', taskCodeExec: '在代码沙盒中运行 AI 生成的代码', taskCommunity: '将对话分享到社区', quotaExhausted: '今日免费次数已用完', upgradeNow: '升级会员', quotaUpgradeHint: '升级会员可获得更多使用次数和全部模型', quotaLoginHint: '登录后可获得更多免费使用次数' },
|
freeMode: '自由模式', learnMode: '学习模式', learnPath: '学习路径', learnPathDesc: '从零到精通,6 步掌握 AI 对话。点击每项文字进入自由模式练习,学会后打钩确认进入下一项', stage: '第 {n} 步', stageProgress: '{done}/{total} 已完成', startPractice: '开始练习', stageDone: '已完成', stageLocked: '未解锁', stageWelcome: 'AI 初体验', stageWelcomeDesc: '了解 AI 能做什么,发起第一次对话', stageScene: '场景实战', stageSceneDesc: '在不同角色场景中练习对话技巧', stageParams: '参数调优', stageParamsDesc: '调节 Temperature 等参数,观察回复变化', stageModels: '模型对比', stageModelsDesc: '切换不同模型,了解各自特点与差异', stagePrompts: '提示词进阶', stagePromptsDesc: '学习角色设定、结构化提示等高级技巧', stageMaster: '综合实战', stageMasterDesc: '综合运用所学,完成一个完整的实战任务', taskSendMsg: '发送第一条消息', taskTryStarter: '尝试一个 Starter 问题', taskReadReply: '理解 AI 回复的特点', taskSwitchCoding: '切换到编程助手场景', taskSwitchWriting: '切换到写作助手场景', taskSwitchStudy: '切换到学习辅导场景', taskHighTemp: '调高 Temperature 到 0.9 试试', taskLowTemp: '调低 Temperature 到 0.1 对比', taskCompareTemp: '对比两次回复的差异', taskSwitchModel: '切换到 DeepSeek 模型', taskCompareModel: '对比不同模型的回复风格', taskRolePrompt: '使用角色设定写一条提示词', taskStructured: '使用结构化提示(步骤化)', taskCodeExec: '在代码沙盒中运行 AI 生成的代码', taskCommunity: '将对话分享到社区', quotaExhausted: '今日免费次数已用完', quotaExhaustedHint: '今日免费次数已用完,欢迎赞助支持我们持续运营', upgradeNow: '升级会员', quotaUpgradeHint: '升级会员可获得更多使用次数和全部模型', quotaLoginHint: '登录后可获得更多免费使用次数' },
|
||||||
learning: { analytics: '学情分析', analyticsDesc: '基于 AI 沙盒对话分析你的学习情况', path: '学习路径', pathDesc: '从入门到精通,系统掌握 AI 技能', totalSessions: 'AI 对话次数', domainsCovered: '涉及知识领域', avgMastery: '平均掌握度', knowledgeDomains: '知识领域覆盖', weakAreas: '薄弱环节', weakDesc: '以下领域你较少涉及,建议加强学习:', recommendations: '推荐学习', recDesc: '根据你的薄弱环节推荐以下内容', toStrengthen: '待加强', conversations: '{count} 次对话', clickToGo: '点击前往' },
|
learning: { analytics: '学情分析', analyticsDesc: '基于 AI 沙盒对话分析你的学习情况', path: '学习路径', pathDesc: '从入门到精通,系统掌握 AI 技能', totalSessions: 'AI 对话次数', domainsCovered: '涉及知识领域', avgMastery: '平均掌握度', knowledgeDomains: '知识领域覆盖', weakAreas: '薄弱环节', weakDesc: '以下领域你较少涉及,建议加强学习:', recommendations: '推荐学习', recDesc: '根据你的薄弱环节推荐以下内容', toStrengthen: '待加强', conversations: '{count} 次对话', clickToGo: '点击前往' },
|
||||||
member: { title: '会员中心', desc: '管理你的会员订阅', currentPlan: '当前会员', freeUser: '你当前是免费用户', monthly: '月卡会员', yearly: '年卡会员', monthlyPrice: '开通月卡 ¥49.9/月', yearlyPrice: '开通年卡 ¥299/年', expires: '到期时间:{date}', benefits: '会员权益:全部课程 + 不限次沙箱 + 专属提示词库 + 去广告', orderHistory: '订单记录', noOrders: '暂无订单记录', processing: '处理中...', planFree: '免费', planMonthly: '月卡', planYearly: '年卡', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/月', perYear: '/年', popular: '最受欢迎', featureSandbox: 'AI 沙盒', featureSandboxFree: '10 次/日', featureSandboxPro: '100 次/日', featureSandboxUnlimited: '不限次', featureModels: '模型选择', featureModelsFree: '1 个模型', featureModelsPro: '2 个模型', featureModelsPremium: '全部模型', featurePrompts: '提示词库', featurePromptsFree: '基础', featurePromptsPro: '全部', featurePromptsPremium: '全部 + 专属', featureCourses: '课程学习', featureCoursesFree: '部分免费', featureCoursesPro: '全部', featureCoursesPremium: '全部', featureAds: '广告', featureAdsFree: '有广告', featureAdsPro: '去广告', featureAdsPremium: '去广告', dailyQuota: '日配额用量', used: '已用 {n} 次', subscribe: '开通', currentPlan_badge: '当前方案', payMethod: '支付方式', wechatPay: '微信支付', alipay: '支付宝支付', scanQrCode: '请使用微信扫描二维码', alipayRedirect: '正在跳转到支付宝...', openAlipay: '打开支付宝', payFailed: '支付失败,请重试', orderPaid: '支付成功!', cancelPay: '取消支付', waitingPay: '等待支付中...' },
|
member: { title: '会员中心', desc: '管理你的会员订阅', currentPlan: '当前会员', freeUser: '你当前是免费用户', monthly: '月卡会员', yearly: '年卡会员', monthlyPrice: '开通月卡 ¥49.9/月', yearlyPrice: '开通年卡 ¥299/年', expires: '到期时间:{date}', benefits: '会员权益:全部课程 + 不限次沙箱 + 专属提示词库 + 去广告', orderHistory: '订单记录', noOrders: '暂无订单记录', processing: '处理中...', planFree: '免费', planMonthly: '月卡', planYearly: '年卡', priceMonthly: '¥49.9', priceYearly: '¥299', perMonth: '/月', perYear: '/年', popular: '最受欢迎', featureSandbox: 'AI 沙盒', featureSandboxFree: '10 次/日', featureSandboxPro: '100 次/日', featureSandboxUnlimited: '不限次', featureModels: '模型选择', featureModelsFree: '1 个模型', featureModelsPro: '2 个模型', featureModelsPremium: '全部模型', featurePrompts: '提示词库', featurePromptsFree: '基础', featurePromptsPro: '全部', featurePromptsPremium: '全部 + 专属', featureCourses: '课程学习', featureCoursesFree: '部分免费', featureCoursesPro: '全部', featureCoursesPremium: '全部', featureAds: '广告', featureAdsFree: '有广告', featureAdsPro: '去广告', featureAdsPremium: '去广告', dailyQuota: '日配额用量', used: '已用 {n} 次', subscribe: '开通', currentPlan_badge: '当前方案', payMethod: '支付方式', wechatPay: '微信支付', alipay: '支付宝支付', scanQrCode: '请使用微信扫描二维码', alipayRedirect: '正在跳转到支付宝...', openAlipay: '打开支付宝', payFailed: '支付失败,请重试', orderPaid: '支付成功!', cancelPay: '取消支付', waitingPay: '等待支付中...' },
|
||||||
compare: { title: '对比实验室', desc: '同题对比不同模型的表现', placeholder: '输入你想对比的问题或提示词...', startCompare: '开始对比', comparing: '对比中...', backToSandbox: '返回沙箱', noResponse: '无响应' },
|
compare: { title: '对比实验室', desc: '同题对比不同模型的表现', placeholder: '输入你想对比的问题或提示词...', startCompare: '开始对比', comparing: '对比中...', backToSandbox: '返回沙箱', noResponse: '无响应' },
|
||||||
codeSandbox: { title: '代码沙盒', run: '运行', runShortcut: '运行 (⌘⏎)', template: '模板...', blank: '空白', react: 'React (CDN)', chart: '图表 (Chart.js)', three: '3D (Three.js)', console: '控制台输出' },
|
codeSandbox: { title: '代码沙盒', run: '运行', runShortcut: '运行 (⌘⏎)', template: '模板...', blank: '空白', react: 'React (CDN)', chart: '图表 (Chart.js)', three: '3D (Three.js)', console: '控制台输出' },
|
||||||
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业', marketing: '营销', business: '商业' } },
|
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业', marketing: '营销', business: '商业' } },
|
||||||
marketplace: { title: '技能广场', desc: '一次性购买,终身使用 — 为你的工作流定制的 AI 技能', free: '免费', buy: '解锁 · ¥{price}', buyNow: '立即购买', buying: '购买中...', purchased: '已拥有', locked: '付费', loginRequired: '请登录后购买', purchaseSuccess: '购买成功!现在可以使用此技能了', unlockToUse: '解锁使用', tryForFree: '试试免费技能', orUpgrade: '浏览更多付费技能', skillMarketplace: '技能广场' },
|
marketplace: { title: '技能广场', desc: '全部开放的 AI 技能库 — 为你的工作流定制的 AI 技能,免费使用', free: '免费', freeNote: '全部技能免费开放,你的支持让平台持续更新', buy: '解锁 · ¥{price}', buyNow: '立即购买', buying: '购买中...', purchased: '已拥有', locked: '付费', loginRequired: '请登录后购买', purchaseSuccess: '购买成功!现在可以使用此技能了', unlockToUse: '解锁使用', tryForFree: '试试免费技能', orUpgrade: '浏览更多付费技能', skillMarketplace: '技能广场' },
|
||||||
promptWorkshop: { title: '提示词工坊', desc: '编写、测试、优化你的提示词', editor: '提示词编辑', test: '测试提示词', testing: '测试中...', clear: '清空', saveToLibrary: '保存到提示词库', saveSuccess: '保存成功!', variables: '变量设置', role: '角色', task: '任务', outputFormat: '输出格式', constraints: '约束条件', insert: '插入', testResult: '测试结果', saveDialogTitle: '保存提示词', saveTitle: '标题 *', saveDesc: '描述', saveTags: '标签', saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试', saving: '保存中...' },
|
promptWorkshop: { title: '提示词工坊', desc: '编写、测试、优化你的提示词', editor: '提示词编辑', test: '测试提示词', testing: '测试中...', clear: '清空', saveToLibrary: '保存到提示词库', saveSuccess: '保存成功!', variables: '变量设置', role: '角色', task: '任务', outputFormat: '输出格式', constraints: '约束条件', insert: '插入', testResult: '测试结果', saveDialogTitle: '保存提示词', saveTitle: '标题 *', saveDesc: '描述', saveTags: '标签', saveTagsPlaceholder: '用逗号分隔,如:编程,Python,调试', saving: '保存中...' },
|
||||||
practices: { title: 'AI 练习', desc: '通过实战场景练习 AI 技能,获得即时评分反馈', submit: '提交答案', submitting: '评分中...', score: '评分', feedback: '反馈', criteria: '评分项', duration: '用时', seconds: '秒', startPractice: '开始练习', yourAnswer: '你的回答', answerPlaceholder: '在此输入你的回答...', hint: '提示', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', categories: { general: '通用', coding: '编程', writing: '写作', analysis: '数据分析', customer: '客服', creative: '创意' }, loginRequired: '请登录后提交练习', alreadySubmitted: '已提交,查看评分', viewSubmission: '查看评分', submissions: '我的提交', noSubmissions: '还没有提交记录', goPractice: '去练习', scoreRange: '{score}/{max} 分', retry: '重新练习', status: { SUBMITTED: '待评分', SCORED: '已评分' } },
|
practices: { title: 'AI 练习', desc: '通过实战场景练习 AI 技能,获得即时评分反馈', submit: '提交答案', submitting: '评分中...', score: '评分', feedback: '反馈', criteria: '评分项', duration: '用时', seconds: '秒', startPractice: '开始练习', yourAnswer: '你的回答', answerPlaceholder: '在此输入你的回答...', hint: '提示', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', categories: { general: '通用', coding: '编程', writing: '写作', analysis: '数据分析', customer: '客服', creative: '创意' }, loginRequired: '请登录后提交练习', alreadySubmitted: '已提交,查看评分', viewSubmission: '查看评分', submissions: '我的提交', noSubmissions: '还没有提交记录', goPractice: '去练习', scoreRange: '{score}/{max} 分', retry: '重新练习', status: { SUBMITTED: '待评分', SCORED: '已评分' } },
|
||||||
packages: { title: '用量包', desc: '购买额外 AI 沙盒使用次数', buy: '立即购买', popular: '最受欢迎', save: '省 {amount}', quota: '{n} 次', price: '¥{price}', buying: '购买中...', package10: '10 次体验包', package10Desc: '适合偶尔使用', package50: '50 次标准包', package50Desc: '¥0.20/次,性价比之选', package300: '300 次畅享包', package300Desc: '¥0.17/次,高频用户首选', currentQuota: '当前用量', dailyQuota: '今日已用 {used}/{limit} 次', extraQuota: '剩余额外次数:{n} 次', quotaExhausted: '免费次数已用完', buyMore: '购买额外次数', purchaseSuccess: '购买成功!已增加 {n} 次使用次数', purchaseFailed: '购买失败,请重试', orUpgrade: '或升级会员获取不限次使用' },
|
packages: { title: '用量包', desc: '购买额外 AI 沙盒使用次数', buy: '立即购买', popular: '最受欢迎', save: '省 {amount}', quota: '{n} 次', price: '¥{price}', buying: '购买中...', package10: '10 次体验包', package10Desc: '适合偶尔使用', package50: '50 次标准包', package50Desc: '¥0.20/次,性价比之选', package300: '300 次畅享包', package300Desc: '¥0.17/次,高频用户首选', currentQuota: '当前用量', dailyQuota: '今日已用 {used}/{limit} 次', extraQuota: '剩余额外次数:{n} 次', quotaExhausted: '免费次数已用完', buyMore: '购买额外次数', purchaseSuccess: '购买成功!已增加 {n} 次使用次数', purchaseFailed: '购买失败,请重试', orUpgrade: '或升级会员获取不限次使用' },
|
||||||
|
donate: { title: '赞助我们', desc: '宇之然坚持免费、开放,你的支持是我们持续更新的动力', supportTitle: '用一杯咖啡,支持我们持续更新', supportDesc: '本平台所有技能、练习、工具指南均免费开放。若它对你有帮助,欢迎自愿打赏表达支持,金额不限。', qrNote: '扫描下方个人收款码,自愿打赏', alipay: '支付宝', wechat: '微信', showQr: '查看收款码', hideQr: '收起', noQr: '收款码配置中,你也可以直接在下方留言表达支持', thanksWall: '感谢墙', thanksWallDesc: '感谢以下朋友的慷慨支持', leaveMessage: '留下你的支持', namePlaceholder: '昵称(选填)', messagePlaceholder: '说点什么吧(选填)', channel: '打赏渠道', submit: '提交感谢', submitting: '提交中...', submitSuccess: '感谢你的支持!', submitFailed: '提交失败,请重试', says: '说', anonymous: '匿名好友', disclaimer: '打赏为自愿赠与行为,平台不提供任何商品或服务对价,不构成经营性收费。', via: '通过{channel}', toDonate: '去赞助', supportUs: '赞助我们', affiliate: '联盟返佣' },
|
||||||
enterprise: { title: '企业版', nav: '企业版', desc: '为团队提供安全、可控的 AI 培训环境', heroTitle: '让团队安全地用好 AI', heroDesc: '75% 的员工在未经IT批准的情况下使用 AI 工具。企业版提供数据隔离的 AI 沙盒环境,让团队在安全可控的范围内学习、练习和应用 AI。', heroCta: '免费试用', heroCtaSub: '无需信用卡', problemTitle: 'Shadow AI 正在威胁你的数据安全', problemDesc: '越来越多的员工在工作中自行使用公共 AI 工具,企业数据面临前所未有的泄漏风险。', statShadowAi: '75%', statShadowAiDesc: '员工使用未经IT批准的AI工具', statDataLeak: '48%', statDataLeakDesc: '员工将公司数据输入公共AI', statIpLeak: '43%', statIpLeakDesc: '企业发生过IP泄漏事件', statTrainingGap: '68%', statTrainingGapDesc: '城市教师未接受AI培训', solutionTitle: '企业版解决方案', solutionDesc: '数据隔离的 AI 培训环境,让团队安全地掌握 AI 技能', featureIsolation: '数据隔离', featureIsolationDesc: '所有对话数据存储在企业专属实例中,不会用于模型训练,也不会泄漏到公共网络', featureManage: '团队管理', featureManageDesc: '一键邀请团队成员,分级管理权限,查看团队使用情况', featureAnalytics: '学习分析', featureAnalyticsDesc: '跟踪团队 AI 技能掌握度,识别薄弱环节,针对性提升', featureSandbox: '专属沙盒', featureSandboxDesc: '成员可在安全隔离的 AI 沙盒中练习,支持主流大模型', featureReport: '使用报告', featureReportDesc: '自动生成团队 AI 使用报告,包含使用量、技能分布和改进建议', featureCustom: '定制培训', featureCustomDesc: '根据业务场景定制练习内容,让 AI 培训与工作直接挂钩', pricingTitle: '灵活定价', pricingDesc: '按团队规模灵活选择', pricingFree: '免费版', pricingFreePrice: '¥0', pricingFreeDesc: '个人体验 AI 学习', pricingFreeFeature1: '10 次/日沙盒使用', pricingFreeFeature2: '1 个模型', pricingFreeFeature3: '基础练习', pricingBiz: '企业版', pricingBizPrice: '¥199', pricingBizPerUser: '/人/年', pricingBizDesc: '团队 AI 技能提升', pricingBizFeature1: '不限次沙盒使用', pricingBizFeature2: '全部模型', pricingBizFeature3: '数据隔离', pricingBizFeature4: '团队管理后台', pricingBizFeature5: '学习分析报告', pricingBizFeature6: '专属客户成功', pricingCta: '联系销售', pricingCtaFree: '免费开始', faqTitle: '常见问题', faq1q: '企业版与免费版有什么区别?', faq1a: '企业版提供数据隔离的专属 AI 环境、团队管理后台、学习分析报告和优先技术支持。所有对话数据不会用于模型训练,确保企业信息安全。', faq2q: '如何邀请团队成员?', faq2a: '创建企业组织后,可以通过邮箱或用户ID邀请成员加入。成员加入后即可使用企业专属的 AI 沙盒和学习资源。', faq3q: '数据安全如何保障?', faq3a: '企业版所有数据存储在独立的数据库实例中,不会与公共版共享。AI 对话数据不会被用于模型训练或改进,确保企业数据隐私。', faq4q: '支持哪些 AI 模型?', faq4a: '企业版支持全部主流 AI 模型,包括 GPT-4、Claude、DeepSeek 等,团队成员可以在沙盒中自由切换对比。', faq5q: '可以按需定制练习内容吗?', faq5a: '可以。企业版支持根据业务场景定制练习题目和评分标准,让 AI 培训与工作实际需求紧密结合。', dashboard: '管理后台', myTeam: '我的团队', createOrg: '创建团队', orgName: '团队名称', orgDesc: '团队描述', members: '成员管理', memberCount: '{n} 人', inviteMember: '邀请成员', inviteById: '用户ID邀请', memberId: '用户ID', invite: '邀请', removeMember: '移除', removeConfirm: '确认移除该成员?', usageReport: '使用报告', totalMembers: '总成员', totalUsage: '总使用次数', completionRate: '完成率', noOrg: '还没有创建团队', createOrgHint: '创建一个团队,开始管理成员和 AI 学习', noMembers: '暂无成员', inviteHint: '邀请成员加入你的团队', learnMore: '了解更多', selectOrg: '选择一个团队查看详情' },
|
enterprise: { title: '企业版', nav: '企业版', desc: '为团队提供安全、可控的 AI 培训环境', heroTitle: '让团队安全地用好 AI', heroDesc: '75% 的员工在未经IT批准的情况下使用 AI 工具。企业版提供数据隔离的 AI 沙盒环境,让团队在安全可控的范围内学习、练习和应用 AI。', heroCta: '免费试用', heroCtaSub: '无需信用卡', problemTitle: 'Shadow AI 正在威胁你的数据安全', problemDesc: '越来越多的员工在工作中自行使用公共 AI 工具,企业数据面临前所未有的泄漏风险。', statShadowAi: '75%', statShadowAiDesc: '员工使用未经IT批准的AI工具', statDataLeak: '48%', statDataLeakDesc: '员工将公司数据输入公共AI', statIpLeak: '43%', statIpLeakDesc: '企业发生过IP泄漏事件', statTrainingGap: '68%', statTrainingGapDesc: '城市教师未接受AI培训', solutionTitle: '企业版解决方案', solutionDesc: '数据隔离的 AI 培训环境,让团队安全地掌握 AI 技能', featureIsolation: '数据隔离', featureIsolationDesc: '所有对话数据存储在企业专属实例中,不会用于模型训练,也不会泄漏到公共网络', featureManage: '团队管理', featureManageDesc: '一键邀请团队成员,分级管理权限,查看团队使用情况', featureAnalytics: '学习分析', featureAnalyticsDesc: '跟踪团队 AI 技能掌握度,识别薄弱环节,针对性提升', featureSandbox: '专属沙盒', featureSandboxDesc: '成员可在安全隔离的 AI 沙盒中练习,支持主流大模型', featureReport: '使用报告', featureReportDesc: '自动生成团队 AI 使用报告,包含使用量、技能分布和改进建议', featureCustom: '定制培训', featureCustomDesc: '根据业务场景定制练习内容,让 AI 培训与工作直接挂钩', pricingTitle: '灵活定价', pricingDesc: '按团队规模灵活选择', pricingFree: '免费版', pricingFreePrice: '¥0', pricingFreeDesc: '个人体验 AI 学习', pricingFreeFeature1: '10 次/日沙盒使用', pricingFreeFeature2: '1 个模型', pricingFreeFeature3: '基础练习', pricingBiz: '企业版', pricingBizPrice: '¥199', pricingBizPerUser: '/人/年', pricingBizDesc: '团队 AI 技能提升', pricingBizFeature1: '不限次沙盒使用', pricingBizFeature2: '全部模型', pricingBizFeature3: '数据隔离', pricingBizFeature4: '团队管理后台', pricingBizFeature5: '学习分析报告', pricingBizFeature6: '专属客户成功', pricingCta: '联系销售', pricingCtaFree: '免费开始', faqTitle: '常见问题', faq1q: '企业版与免费版有什么区别?', faq1a: '企业版提供数据隔离的专属 AI 环境、团队管理后台、学习分析报告和优先技术支持。所有对话数据不会用于模型训练,确保企业信息安全。', faq2q: '如何邀请团队成员?', faq2a: '创建企业组织后,可以通过邮箱或用户ID邀请成员加入。成员加入后即可使用企业专属的 AI 沙盒和学习资源。', faq3q: '数据安全如何保障?', faq3a: '企业版所有数据存储在独立的数据库实例中,不会与公共版共享。AI 对话数据不会被用于模型训练或改进,确保企业数据隐私。', faq4q: '支持哪些 AI 模型?', faq4a: '企业版支持全部主流 AI 模型,包括 GPT-4、Claude、DeepSeek 等,团队成员可以在沙盒中自由切换对比。', faq5q: '可以按需定制练习内容吗?', faq5a: '可以。企业版支持根据业务场景定制练习题目和评分标准,让 AI 培训与工作实际需求紧密结合。', dashboard: '管理后台', myTeam: '我的团队', createOrg: '创建团队', orgName: '团队名称', orgDesc: '团队描述', members: '成员管理', memberCount: '{n} 人', inviteMember: '邀请成员', inviteById: '用户ID邀请', memberId: '用户ID', invite: '邀请', removeMember: '移除', removeConfirm: '确认移除该成员?', usageReport: '使用报告', totalMembers: '总成员', totalUsage: '总使用次数', completionRate: '完成率', noOrg: '还没有创建团队', createOrgHint: '创建一个团队,开始管理成员和 AI 学习', noMembers: '暂无成员', inviteHint: '邀请成员加入你的团队', learnMore: '了解更多', selectOrg: '选择一个团队查看详情' },
|
||||||
assistant: { title: 'AI 助手', greeting: '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:', placeholder: '输入你的问题...', error: '出错啦:{message}', loginPrompt: '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。' },
|
assistant: { title: 'AI 助手', greeting: '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:', placeholder: '输入你的问题...', error: '出错啦:{message}', loginPrompt: '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { SITE_URL } from './config';
|
||||||
|
|
||||||
|
export function absoluteUrl(path: string): string {
|
||||||
|
if (/^https?:\/\//.test(path)) return path;
|
||||||
|
return `${SITE_URL}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articleJsonLd(data: {
|
||||||
|
headline: string;
|
||||||
|
description?: string;
|
||||||
|
url: string;
|
||||||
|
datePublished?: string;
|
||||||
|
image?: string;
|
||||||
|
authorName?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: data.headline,
|
||||||
|
description: data.description || '',
|
||||||
|
url: data.url,
|
||||||
|
...(data.datePublished ? { datePublished: data.datePublished } : {}),
|
||||||
|
author: { '@type': 'Organization', name: data.authorName || '宇之然 AI', url: SITE_URL },
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: '宇之然 AI',
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: { '@type': 'ImageObject', url: `${SITE_URL}/favicon.png` },
|
||||||
|
},
|
||||||
|
...(data.image ? { image: data.image } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function softwareAppJsonLd(data: { name: string; description?: string; url: string }) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || '',
|
||||||
|
url: data.url,
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web',
|
||||||
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
|
||||||
|
publisher: { '@type': 'Organization', name: '宇之然 AI', url: SITE_URL },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breadcrumbJsonLd(items: { name: string; url: string }[]) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: items.map((it, i) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: i + 1,
|
||||||
|
name: it.name,
|
||||||
|
item: it.url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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