Compare commits

..

14 Commits

Author SHA1 Message Date
TradeMate Dev 4ee0e546e3 fix: 清理 typecheck 报错并修正迁移列名
- 加入缺失的 @testing-library/dom 依赖(RTL v16 必需 peer),修复 screen 导入
- 修正测试文件错误的相对导入路径(header 改具名导入;prompts/courses 指向 ../page)
- 修正联盟/打赏迁移 SQL:Prisma 列名为 camelCase(仅 @@map 映射表名),原 snake_case 列名导致查询 500,已重建表
- npm run typecheck 现已通过(EXIT=0)
2026-07-11 20:00:14 +08:00
TradeMate Dev 6850ceff56 fix(seo): 详情页服务端渲染写入静态HTML并修复skills预渲染报错
- courses/skills/contents/practices 详情页改为服务端取数 + initialData 渲染,正文进入静态 HTML(解决百度弱 JS 收录盲点)
- contents 的 markdown 由 useEffect+setState 改为 useMemo 同步输出
- 四个 generateStaticParams 改用 API 真实 id;getX 校验 data.id 防后端 200+error 体被当有效数据导致 undefined.map 报错
- 更新进度文档至 v1.3.0
2026-07-11 17:46:31 +08:00
TradeMate Dev 09bf329544 feat(seo): 结构化数据/每页元数据/列表页metadata/hreflang修正/OG分享图
- 详情页注入 Article+BreadcrumbList JSON-LD,工具页 SoftwareApplication
- 课程/技能/文章/练习 动态 generateMetadata(canonical/OG)
- 新增 skills/contents/practices/prompts/models 列表页 metadata
- 修正 hreflang(删除不存在的/en),用 next/og 生成 OG 分享图
2026-07-11 17:23:56 +08:00
TradeMate Dev 8f13604e0e chore: 全站改为免费+联盟模式,原支付保留备用
- 课程详情页下架付费购买与扫码支付弹窗
- 首页/能力导览文案 购买→免费使用
- 付费面(会员/技能广场/用量包/课程)均已免费化,原 orders/payment 模块保留后端备用
2026-07-11 17:08:32 +08:00
TradeMate Dev 00a3904eba feat: 落地打赏(个人码捐赠)模式并将付费面改为免费+赞助(无ICP证合规)
- 新增 Donation 模型/迁移/后端 API(GET,POST /donations)
- 新增前端 /donate 页:收款码+感谢留言+感谢墙
- 会员页/技能广场/用量包 下架付费,改免费开放+赞助入口
- 沙箱用量耗尽引导至 /donate 赞助
- 导航与页脚新增 赞助/联盟返佣 入口,补充 i18n
2026-07-11 15:02:08 +08:00
TradeMate Dev 31cc1e02ce feat: 联盟返佣系统规范化并打通技能广场(无 ICP 证合规变现主路径)
- 新增 AffiliateProgram / AffiliateLink / AffiliateClick 规范化 Prisma 模型
  取代原手写裸表 affiliate_stats / affiliate_clicks
- 新增迁移 prisma/migrations/20260711000000_add_affiliate_models
- 重构 affiliate.service.ts 改用 Prisma ORM,消除 $queryRawUnsafe SQL 注入
- 重构 affiliate.controller.ts 接口:programs / links(?skillId,?toolId) / stats / click
- 前端 affiliate 页接入真实接口,移除硬编码 demo 数据
- 技能详情页新增「学此技能推荐使用的工具」联盟链接区块
- 新增 affiliate.service.spec.ts(4 用例通过)与幂等种子 seed-affiliate.ts
- 更新 docs/progress/current.md,明确无 ICP 经营许可证下以联盟返佣为合规变现主路径
- 含此前工作区未提交改动(工具 slug 路由、支付/订单、SEO 等)

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-11 14:47:41 +08:00
TradeMate Dev 05bf267d09 docs: update progress with P8.4 - @Res() bug fix, model verification, production deploy 2026-06-24 11:32:55 +08:00
TradeMate Dev 8a86334d05 fix: sandbox non-stream response not sent due to @Res() express native mode
- sandbox.controller.ts: non-stream chat path now calls res.json() instead
  of returning the value (which NestJS discards in @Res() library mode)
- ai-gateway.service.ts: 商汤科技 only registers sensenova model, no longer
  overwrites deepseek registration
- .gitignore: add .playwright-mcp/ and gitea-login.png
2026-06-23 23:21:28 +08:00
TradeMate Dev f4f17c10d8 build: 添加 postbuild sitemap 生成脚本 + robots.txt SEO
- package.json: 添加 postbuild 钩子自动生成 sitemap
- scripts/generate-sitemap.js: 站点地图生成器(78 条 URL)
- public/robots.txt: SEO 爬虫引导

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:24:20 +08:00
TradeMate Dev c10a33e9f8 docs: 新增返佣注册指南 + 进度更新至 P8.3
- affiliate-registration.md: 收录阿里云/百度云/腾讯云/Canva/WPS/讯飞
  等平台的注册入口、佣金比例、操作流程
- progress.md: 新增 P8.2(返佣工具库扩充)和 P8.3(模型合规优化)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:24:17 +08:00
TradeMate Dev 5ddd7e3a82 feat: 数据层更新 — 新增国内工具种子脚本 + schema 优化
- schema.prisma: Prompt.content 改为 @db.Text 支持长文本
- seed-enrich.ts: deleteMany()→upsert by name 防止重跑时清除数据
- seed-expand.ts: 扩展 prompts 至 35 条 + 课程章节扩充
- seed-tools-affiliate.ts: 新增 8 个国内 AI 工具(豆包/通义千问/文心一言/
  WPS AI/Canva/腾讯元宝/讯飞星火/剪映),5 个带返佣链接

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:53 +08:00
TradeMate Dev 15ff424f11 feat: Admin 工具管理页新增返佣链接字段
- new/page.tsx: 表单添加 affiliateLink 输入字段 + 提示文案
- [id]/client.tsx: 从只读展示重写为可编辑表单,调用 PUT /admin/tools/:id 保存
                   添加 affiliateLink 字段,修复原页面不保存的 Bug

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:49 +08:00
TradeMate Dev 7c92729c3f feat: 前端沙盒模型列表仅显示国内已备案模型
- models.ts 移除 GPT/Claude/Gemini/Llama,仅保留 domestic 模型
- DEFAULT_MODEL 改为 deepseek-v4-flash(原为 general)
- ModelSelector 改为从 /sandbox/models 获取可用模型列表

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:25 +08:00
TradeMate Dev cd0ef01c7c feat: 沙盒模型合规优化 — 移除境外模型引用,新增 GET /sandbox/models 接口
- MODEL_CATALOG 仅保留国内已备案模型(DeepSeek/SenseNova)
- chat()/chatStream() modelMap 仅路由到国内 provider
- provider 命名明确化:"OpenAI 兼容接口" → "硅基流动 / DeepSeek"
- 新增 getSandboxModels() 动态返回可用模型列表
- sandbox 控制器拆分 auth 级别,GET /sandbox/models 无需认证
- 新增 getAvailableModels() 供前端调用

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:21 +08:00
81 changed files with 5972 additions and 2391 deletions
+6
View File
@@ -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/
+35 -122
View File
@@ -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 + MySQLschema 在 `prisma/schema.prisma`
- **认证**: JWT + Passport
- **API 前缀**: `/api/v1`
- **CORS**: `origin: true`, `maxAge: 0`(避免浏览器预检缓存)
### UI/UX 统一规则
- **容器**: 内容页 `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12`;法律/文本页用 `max-w-3xl`
- **标题**: `text-3xl font-bold text-foreground`
- **副标题**: `<p className="mt-2 text-muted-foreground">...</p>`
- **颜色**: 全站禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` 硬编码 —— 使用 CSS 变量:
- 前景色 → `text-foreground` / `text-muted-foreground`
- 背景 → `bg-card` / `bg-muted/50` / `bg-accent`
- 边框 → `border-border`
- hover → `hover:text-foreground` / `hover:bg-accent`
- **国际化**: 所有用户可见文本使用 `useT()` hook,翻译 key 位于 `src/i18n/locales/`。新增页面先加翻译 key 再写 UI。中文默认,英文同步维护。
## 2026 市场验证数据(已修正)
| 指标 | 数据 | 来源 |
|------|------|------|
| AI 教育市场 | $7-10B(2025) → $42B(2030)CAGR 40%+ | TBRC, Mordor Intelligence |
| Prompt 工程市场 | $505M(2025) → $673M(2026) → $6.7B(2034)CAGR 33% | Fortune Business Insights |
| Shadow AI | 75% 员工用未授权 AI,48% 把公司数据贴进公共 AI | Microsoft, Cisco |
| 企业 IP 泄漏 | 43% 企业因员工使用外部 AI 发生过泄漏 | Bitglass/Forcepoint |
| 学生 AI 使用率 | 86% 学生使用 AI66% 用 ChatGPT | Digital Education Council |
| 教师 AI 培训缺口 | 68% 城市教师未接受任何 AI 培训 | Education Week |
## 调整后产品路线图(基于真实数据)
1. **AI 工具指南(最高优先级)** ✅ — 收录优质 AI 工具,联盟返佣变现,首页展示精选工具
2. **练习系统** ✅ — Prompt 工程市场 $673M(2026),练习+评分是已验证的付费模式
3. **¥9.9 用量包** ✅ — 免费 5 次 → 付费 50 次,从沙盒使用量直接变现
4. **技能广场** ✅ — 一次性购买技能,¥9.9-29.9,独立变现渠道
5. ~~企业版~~ ↓ — 从主菜单隐藏,页面仍可通过 URL 访问,不主动运营
6. ~~社区/圈子~~ ↓ — 从主菜单隐藏,页面仍可访问,不主动运营
5. **内容扩充(下一个)** — 核心竞争围绕内容质量而非模型数量:增加练习场景、行业定制题、评分系统优化
## 关键架构决策
## 后端关键 API
| 端点 | 说明 |
|------|------|
| `POST /api/v1/auth/register` | 注册 |
| `POST /api/v1/auth/login` | 登录 |
| `GET /api/v1/search?q=xxx` | 搜索(返回 `{ results: [...] }`**不是** `items` |
| `GET /api/v1/users/:id` | 用户信息 |
| `POST /api/v1/chat/completions` | AI 流式对话 |
| `GET /api/v1/enterprise/...` | 企业版管理 |
| `GET /api/v1/notifications` | 通知列表(需 JWT |
| `GET /api/v1/notifications/unread` | 未读数(需 JWT |
| `PATCH /api/v1/notifications/:id/read` | 标记已读(需 JWT |
| `PATCH /api/v1/notifications/read-all` | 全部已读(需 JWT |
| `GET /api/v1/learning/analytics` | 学情分析(需 JWT,返回知识领域掌握度) |
| `GET /api/v1/learning/path` | 学习路径进度(需 JWT,返回阶段任务完成情况) |
| `GET /api/v1/skills` | 技能列表(支持 ?category= / ?difficulty= / ?search= 过滤) |
| `GET /api/v1/skills/:id` | 技能详情(含 system prompt、练习任务、starter 问题,返回 purchased/locked 状态) |
| `GET /api/v1/skills/marketplace` | 技能广场(返回所有技能含 price + purchased 状态,无需认证也可用) |
| `GET /api/v1/skills/categories` | 技能分类列表 |
| `GET /api/v1/skills/difficulties` | 难度等级列表 |
## 关键上下文(Critical Context
- `search.service.ts` 返回 `results` 而非 `items` —— 写测试时注意
- `users.service.ts``if (status)` 曾误写为 `if (params.status)`(已修复)
- 静态构建 (`next build`) 已修复 —— 所有动态路由都通过 server wrapper 模式导出 `generateStaticParams()`60 页面全部生成)
- `public/favicon.ico``app/favicon.ico` 不能共存,会触发 500
- ESLint 循环引用已在 `next.config.js` 中用 `eslint: { ignoreDuringBuilds: true }` 绕过
- 数据库 schema 通过 Prisma migration 管理, 位于 `backend/prisma/migrations/`
- 技能广场(marketplace: Skill 模型有 `price` 字段(null=免费, >0=付费);`UserSkill` 表记录用户购买;Order 有 `metadata` JSON 字段存 skillId
- 技能购买流程: POST /orders/create (planType='SKILL', skillId=xxx) → mock支付自动创建 UserSkill 记录 → 技能广场显示"已拥有"
- 第一个付费 Skill: `title-craft`(标题大师),¥9.9seed 在 `prisma/seed-marketplace.cjs`
- 后续 5 个付费 Skill: `payment-chaser`(回款助手 ¥19.9), `proposal-wizard`(提案智造 ¥19.9), `review-intel`(差评分析师 ¥19.9), `listing-optimizer`(商品文案大师 ¥29.9), `contract-reviewer`(合同审查助手 ¥29.9)
## 项目管理流程
每次任务遵循以下流程:
1. **任务开始前** — 读 AGENTS.md + docs/progress.md,了解当前进度和上下文
2. **规划阶段** — 分析需求,拆解为 TODO 列表,按优先级排序
3. **执行阶段** — 按 TODO 依次实施,完成后先做**代码评审修复**(检查代码风格、类型安全、边界情况、安全隐患、是否符合项目约定),再做**测试验证**(运行 lint/build + 相关测试,发现失败立即修复)
4. **任务完成后** — 更新 docs/progress.md(更新阶段状态、补充完成项)
5. **归档** — 旧版 progress.md 移到 `docs/archive/progress-YYYY-MM-DD.md`
6. **提交** — 只有在用户明确要求时才创建 git commit
## 常用命令 ## 常用命令
```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 # vitestsrc/**/*.{test,spec}.{ts,tsx}
cd frontend && npm run test:e2e # playwrightBASE_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 # e2etest/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;
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/**
* 联盟返佣种子:创建「阿里云云大使」推广计划,并把技能/工具关联到推广链接。
* 幂等(upsert),可重复执行。部署时运行:npx ts-node prisma/seed-affiliate.ts
*/
async function main() {
const program = await prisma.affiliateProgram.upsert({
where: { slug: 'aliyun-promoter' },
update: { active: true },
create: {
name: '阿里云云大使',
slug: 'aliyun-promoter',
provider: '阿里云',
description: '阿里云推广大使联盟,用户通过推荐链接购买云产品,平台获得返佣。',
commissionRate: '最高 20%',
cookieDays: 30,
homepageUrl: 'https://promotion.aliyun.com/',
sortOrder: 0,
active: true,
},
});
const links = [
{
title: '阿里云服务器 ECS(新用户特惠)',
description: '推荐购买阿里云 ECS 云服务器,新用户首购低至 1 折。',
url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud',
skillSlug: 'title-craft',
toolName: '通义千问',
tags: '云计算,服务器,阿里云',
estimatedCommission: 15,
isFeatured: true,
},
{
title: '阿里云 AI 大模型 API(百炼平台)',
description: '通义千问/百炼大模型 API,按量计费,适合开发者接入。',
url: 'https://bailian.aliyun.com/?userCode=yzrcloud',
skillSlug: 'payment-chaser',
toolName: '通义千问',
tags: '大模型,API,AI',
estimatedCommission: 12,
isFeatured: true,
},
{
title: '阿里云对象存储 OSS',
description: '推荐企业/开发者使用 OSS 存储,长期稳定低成本。',
url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud',
skillSlug: null,
toolName: 'WPS AI',
tags: '存储,对象存储',
estimatedCommission: 8,
isFeatured: false,
},
];
for (const link of links) {
let skillId: string | null = null;
if (link.skillSlug) {
const skill = await prisma.skill.findUnique({ where: { id: link.skillSlug } });
skillId = skill?.id ?? null;
}
let toolId: number | null = null;
if (link.toolName) {
const tool = await prisma.tool.findFirst({ where: { name: link.toolName } });
toolId = tool?.id ?? null;
}
await prisma.affiliateLink.upsert({
where: { title: link.title },
update: { url: link.url, active: true, skillId, toolId },
create: {
programId: program.id,
title: link.title,
description: link.description,
url: link.url,
tags: link.tags,
estimatedCommission: link.estimatedCommission,
isFeatured: link.isFeatured,
skillId,
toolId,
active: true,
},
});
}
const count = await prisma.affiliateLink.count();
console.log(`✅ 联盟返佣种子完成,当前推广链接 ${count}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+8 -4
View File
@@ -339,9 +339,8 @@ async function main() {
} }
console.log(`${banners.length} banners`); console.log(`${banners.length} banners`);
// ===== 6. More Tools (6 more) ===== // ===== 6. Tools (6 flagship, upsert by name) =====
console.log('Seeding additional tools...'); console.log('Seeding tools...');
await prisma.tool.deleteMany();
const tools = [ const tools = [
{ name: 'DeepSeek', description: '深度求索开发的 AI 大模型,中文能力突出,API 价格极低。支持开源模型私有化部署,适合数据敏感场景。', url: 'https://chat.deepseek.com', categoryId: defaultCatIds['ai-tools'], tags: '对话,开源,中文,低成本', isFeatured: true, status: 'PUBLISHED' as const }, { name: 'DeepSeek', description: '深度求索开发的 AI 大模型,中文能力突出,API 价格极低。支持开源模型私有化部署,适合数据敏感场景。', url: 'https://chat.deepseek.com', categoryId: defaultCatIds['ai-tools'], tags: '对话,开源,中文,低成本', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Midjourney', description: '领先的 AI 图像生成工具,以高质量艺术风格著称。支持文本生成图像、图像变体、风格迁移等。', url: 'https://midjourney.com', categoryId: defaultCatIds['ai-tools'], tags: '图像,创意,设计', isFeatured: true, status: 'PUBLISHED' as const }, { name: 'Midjourney', description: '领先的 AI 图像生成工具,以高质量艺术风格著称。支持文本生成图像、图像变体、风格迁移等。', url: 'https://midjourney.com', categoryId: defaultCatIds['ai-tools'], tags: '图像,创意,设计', isFeatured: true, status: 'PUBLISHED' as const },
@@ -351,7 +350,12 @@ async function main() {
{ name: 'Runway Gen-4', description: '领先的 AI 视频生成工具,支持文本生成视频、图像生成视频、视频编辑等。好莱坞级视频制作能力。', url: 'https://runwayml.com', categoryId: defaultCatIds['ai-tools'], tags: '视频,创意,生成', status: 'PUBLISHED' as const }, { name: 'Runway Gen-4', description: '领先的 AI 视频生成工具,支持文本生成视频、图像生成视频、视频编辑等。好莱坞级视频制作能力。', url: 'https://runwayml.com', categoryId: defaultCatIds['ai-tools'], tags: '视频,创意,生成', status: 'PUBLISHED' as const },
]; ];
for (const t of tools) { for (const t of tools) {
await prisma.tool.create({ data: t }); const existing = await prisma.tool.findFirst({ where: { name: t.name } });
if (existing) {
await prisma.tool.update({ where: { id: existing.id }, data: t });
} else {
await prisma.tool.create({ data: t });
}
} }
console.log(`${tools.length} tools`); console.log(`${tools.length} tools`);
+131
View File
@@ -0,0 +1,131 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Expanding content...\n');
// ===== 1. Add more prompts (to reach 30+) =====
console.log('Seeding additional prompts...');
const catIds: Record<string, number> = {};
const cats = await prisma.category.findMany();
for (const c of cats) {
catIds[c.slug] = c.id;
}
const additionalPrompts = [
// Creative Design
{ title: 'Logo 设计需求说明生成', content: '请帮我生成一份 AI Logo 设计需求说明。\n\n品牌名称:[品牌名]\n行业:[行业]\n品牌调性:[现代/传统/科技/自然]\n偏好颜色:[颜色/风格]\n\n请输出:\n1. 3 个设计方向关键词\n2. 详细的设计需求描述(供设计师/Midjourney 使用)\n3. 参考风格建议\n4. 避免的设计风格', description: '用 AI 生成专业的 Logo 设计需求说明', categoryId: catIds['creative-design'], tags: '设计,Logo,品牌', model: '通用', status: 'PUBLISHED' as const },
{ title: '视频脚本生成器', content: '请为我生成一个短视频脚本。\n\n主题:[视频主题]\n目标受众:[描述受众]\n视频时长:[15秒/30秒/60秒]\n风格:[教育/搞笑/感人/促销]\n平台:[抖音/小红书/YouTube Shorts]\n\n脚本结构:\n1. 前 3 秒钩子(吸引注意力)\n2. 正文内容(3-5 个要点)\n3. 结尾 Call-to-Action\n4. 画面描述和字幕建议', description: 'AI 帮你写短视频脚本', categoryId: catIds['creative-design'], tags: '视频,脚本,短视频,抖音', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '配色方案生成器', content: '请根据以下信息生成配色方案。\n\n品牌/项目:[名称]\n行业:[行业]\n基调:[活泼/专业/奢华/清新]\n主要受众:[用户群体]\n\n请生成:\n1. 主色(1 个 HEX 色值)\n2. 辅助色(2-3 个)\n3. 强调色(1-2 个)\n4. 背景色(1-2 个)\n5. 文字色(深色/浅色各 1 个)\n6. 这套方案的心理学依据', description: 'AI 帮你设计配色方案', categoryId: catIds['creative-design'], tags: '设计,配色,UI', model: '通用', status: 'PUBLISHED' as const },
// Office Productivity
{ title: '会议议程生成器', content: '请帮我制定一个[会议主题]的议程。\n\n会议主题:[描述]\n参会人员:[角色列表]\n会议时长:[多少分钟]\n会议目标:[要达成的目标]\n\n请输出:\n1. 会议目标(清晰可衡量)\n2. 时间分配表(每个环节的时长和负责人)\n3. 前置准备工作\n4. 预期产出\n5. 跟进方式', description: 'AI 帮你制定高效的会议议程', categoryId: catIds['office-productivity'], tags: '会议,效率,办公', model: '通用', status: 'PUBLISHED' as const },
{ title: 'OKR 制定助手', content: '请帮我制定 OKR(目标和关键结果)。\n\n团队/个人:[名称]\n周期:[2026 Q3/Q4/年度]\n公司目标:[如是团队 OKR,需对齐的上级目标]\n\n请输出:\n1. 2-3 个 Objectives(目标,有挑战性但可达)\n2. 每个 O 下 3-4 个 Key Results(可量化)\n3. 每个 KR 的信心指数(5/10 → 10/10\n4. KR 的衡量标准', description: 'AI 辅助制定 OKR', categoryId: catIds['office-productivity'], tags: 'OKR,目标管理,效率', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '项目复盘模板', content: '请根据以下信息生成项目复盘报告模板。\n\n项目名称:[项目名]\n项目周期:[起止时间]\n团队成员:[角色列表]\n\n请按以下结构输出复盘框架:\n1. 项目概况(目标、范围、里程碑)\n2. 数据回顾(完成度、进度偏差、质量指标)\n3. 亮点(What went well\n4. 改进点(What could be improved\n5. 行动计划(具体负责人和截止时间)\n6. 经验沉淀(可复用的最佳实践)', description: 'AI 帮你做项目复盘', categoryId: catIds['office-productivity'], tags: '项目,复盘,管理', model: '通用', status: 'PUBLISHED' as const },
// Programming
{ title: 'Python 脚本生成助手', content: '请帮我写一个 Python 脚本来完成以下任务。\n\n任务描述:[详细描述]\n输入格式:[CSV/JSON/...]\n输出格式:[CSV/JSON/打印/...]\n依赖要求:[允许/不允许使用哪些库]\n\n请生成:\n1. 完整的 Python 脚本\n2. 使用方法和参数说明\n3. 错误处理逻辑\n4. 测试用例示例', description: 'AI 帮你写 Python 工具脚本', categoryId: catIds['programming'], tags: 'Python,脚本,自动化', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'React 组件生成器', content: '请帮我生成一个 React 组件。\n\n组件描述:[组件功能]\nProps[输入参数列表]\nUI 框架:[shadcn/ui / Material UI / Antd / 无]\n状态管理:[useState / zustand / redux / 无]\n\n请生成:\n1. 完整的组件代码(TypeScript\n2. Props 类型定义\n3. 使用示例\n4. 边缘情况处理', description: 'AI 帮你生成 React 组件', categoryId: catIds['programming'], tags: 'React,组件,前端,TypeScript', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'Git 命令顾问', content: '请告诉我如何用 Git 完成以下操作。\n\n我需要:[描述你要用 Git 做什么,如:撤销上 3 次提交但保留工作区修改]\n\n请给出:\n1. 具体的 Git 命令序列\n2. 每个命令的作用\n3. 是否有风险(如:会丢失未提交的修改)\n4. 更安全的替代方案(如果有)', description: 'AI Git 命令顾问', categoryId: catIds['programming'], tags: 'Git,版本控制,开发', model: '通用', status: 'PUBLISHED' as const },
// Prompt Engineering
{ title: 'Prompt 优化器', content: '请帮我优化以下提示词,使其效果更好。\n\n原始提示词:\n[粘贴你的原始提示词]\n\n使用场景:[描述你用它做什么]\n目前问题:[效果不好/结果不稳定/格式不对]\n\n请输出:\n1. 优化后的提示词\n2. 做了哪些改进(角色设定/格式约束/示例添加等)\n3. 为什么这样改有效\n4. 备选方案', description: 'AI 帮你优化提示词', categoryId: catIds['prompt-engineering'], tags: 'Prompt,提示词,优化', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '结构化输出生成器', content: '请生成一个提示词模板,让 AI 总是按指定结构输出。\n\n任务:[AI 要完成的任务]\n输出字段:\n- [字段1]:[类型和说明]\n- [字段2]:[类型和说明]\n- [字段3][类型和说明]\n\n请生成:\n1. 完整的系统提示词(System Prompt\n2. 输出格式定义(JSON Schema 或 Markdown 模板)\n3. 示例输出\n4. 处理 AI 不遵循格式的降级策略', description: 'AI 帮你设计结构化输出模板', categoryId: catIds['prompt-engineering'], tags: 'Prompt,结构化,JSON,输出', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'Prompt 链设计器', content: '请帮我设计一个多步骤的 Prompt 链(Chain of Prompts)。\n\n最终目标:[描述最终要完成的任务]\n\n请设计:\nStep 1: [分析/理解] - 提示词 + 预期输出\nStep 2: [规划/构思] - 提示词 + 预期输出\nStep 3: [生成/执行] - 提示词 + 预期输出\nStep 4: [优化/完善] - 提示词 + 预期输出\n\n每个 Step 包含:\n- 提示词内容\n- 输入来源(上一步的输出)\n- 输出格式\n- 质量检查方式', description: 'AI 设计多步骤 Prompt 工作流', categoryId: catIds['prompt-engineering'], tags: 'Prompt链,工作流,高级', model: 'GPT-5', status: 'PUBLISHED' as const },
// AI Basics category
{ title: 'AI 工具选型指南', content: '请帮我选择最适合的 AI 工具。\n\n使用场景:[描述你要用 AI 做什么]\n预算:[每月预算]\n技术能力:[无/基础/中级/高级]\n主要需求:[功能列表]\n\n请推荐:\n1. 最佳选择(推荐工具 + 理由)\n2. 性价比选择(如果预算有限)\n3. 免费替代方案\n4. 多工具组合建议', description: 'AI 帮你选合适的 AI 工具', categoryId: catIds['ai-basics'], tags: 'AI,选型,工具推荐', model: '通用', status: 'PUBLISHED' as const },
{ title: 'AI 风险自查表', content: '请帮我评估使用 [AI 工具/模型] 的潜在风险。\n\n使用的 AI:\n用途:\n涉及数据类型:[公开/内部/机密/个人隐私]\n\n请评估以下风险:\n1. 数据隐私风险(数据是否会上传到第三方)\n2. 输出可靠性风险(幻觉倾向)\n3. 合规风险(是否符合行业法规)\n4. 供应商锁定风险\n5. 降低风险的措施建议', description: 'AI 使用风险评估', categoryId: catIds['ai-basics'], tags: 'AI,风险,安全,合规', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'AI 学习路径规划', content: '请帮我制定一个 AI 学习路径。\n\n当前水平:[零基础/有编程基础/有 ML 基础]\n学习目标:[想成为 AI 产品经理/AI 工程师/AI 应用开发者]\n可用时间:[每周多少小时]\n学习周期:[几个月]\n\n请输出:\n1. 按月划分的学习路线图\n2. 每月核心学习目标\n3. 推荐课程/资源(优先免费)\n4. 实践项目建议\n5. 常见误区提醒', description: 'AI 帮你规划 AI 学习路径', categoryId: catIds['ai-basics'], tags: 'AI,学习,路径,教程', model: '通用', status: 'PUBLISHED' as const },
// Education
{ title: '教案生成助手', content: '请帮我生成一份教案。\n\n课程主题:[主题]\n适用年级/对象:[年级或人群]\n课时长度:[多少分钟]\n学生基础:[已有知识]\n\n教案结构:\n1. 教学目标(知识/技能/素养三个维度)\n2. 教学重难点\n3. 教学过程(导入→新授→练习→总结,含时间分配)\n4. 教学资源清单\n5. 评价方式\n6. 备选活动(如果时间有余)', description: 'AI 帮你写教案', categoryId: catIds['education'], tags: '教育,教案,教学', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '考试题生成器', content: '请根据以下内容生成考试题目。\n\n知识点:[列出知识点]\n题型:[选择题/填空题/简答题/编程题]\n难度:[简单/中等/困难]\n题量:[多少道]\n对象:[学生/求职者/培训学员]\n\n要求:\n1. 每道题附参考答案\n2. 选择题需 4 个选项\n3. 标明每题考察的知识点\n4. 简单:中等:困难 = 3:5:2 的比例', description: 'AI 自动生成考试题目', categoryId: catIds['education'], tags: '教育,考试,题目', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '论文摘要改写', content: '请帮我改写/润色以下论文摘要。\n\n原文摘要:\n[粘贴摘要]\n\n目标期刊:[期刊名称/风格]\n字数限制:[多少字]\n是否需要突出创新点:[是/否]\n\n请输出:\n1. 改写后的摘要\n2. 改写说明(改了什么、为什么改)\n3. 关键词建议(3-5 个)\n4. 投稿建议', description: 'AI 帮你改写论文摘要', categoryId: catIds['education'], tags: '论文,学术,写作', model: 'GPT-5', status: 'PUBLISHED' as const },
];
for (const p of additionalPrompts) {
await prisma.prompt.create({ data: p });
}
console.log(`${additionalPrompts.length} additional prompts created`);
// ===== 2. Expand courses to match descriptions =====
console.log('\nSeeding additional course chapters & lessons...');
const [course1, course2, course3] = await prisma.course.findMany({ orderBy: { id: 'asc' } });
// Course 1: Prompt Engineering - Add practice chapters
const course1Ch1 = await prisma.chapter.findFirst({ where: { courseId: course1.id, sortOrder: 1 } });
const course1Ch2 = await prisma.chapter.findFirst({ where: { courseId: course1.id, sortOrder: 2 } });
if (course1Ch2) {
// Add Chapter 3: 实战案例(写作场景)
const ch3 = await prisma.chapter.create({
data: { courseId: course1.id, title: '第三章:写作场景实战', sortOrder: 3, lessons: { create: [
{ title: 'AI 辅助博客文章写作', content: '# AI 辅助博客文章写作\n\n## 场景描述\n作为技术博主,每周需要产出 2-3 篇高质量文章。AI 可以帮助你完成选题、提纲、初稿和润色全流程。\n\n## Prompt 模板\n\n```\n你是一位资深技术博主,擅长用通俗易懂的语言解释复杂概念。\n\n请帮我写一篇关于[主题]的博客文章。\n\n目标读者:[描述读者背景]\n文章目的:[教学/观点/教程]\n字数:约 1500 字\n\n要求:\n1. 开头用一个引人入胜的问题或故事\n2. 正文分 3-4 个小节,每节有小标题\n3. 包含实际的代码/案例\n4. 结尾有总结和行动建议\n```\n\n## 实战技巧\n\n1. **选题阶段**:让 AI 帮你想 10 个选题,选最有共鸣的\n2. **提纲阶段**:先让 AI 出提纲,你调整后再写正文\n3. **润色阶段**:完成初稿后,让 AI 优化可读性和 SEO\n\n## 练习\n\n用上面的模板,让 AI 写一篇"2026 年 AI 编程工具对比"的博客文章。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 辅助营销文案写作', content: '# AI 辅助营销文案写作\n\n## 场景描述\n营销文案的 ROI 直接取决于文案质量。AI 可以帮助你快速生成 A/B 测试文案、优化转化率。\n\n## 核心框架:AIDA\n\n- **Attention**(注意):吸引眼球的标题\n- **Interest**(兴趣):让读者产生兴趣\n- **Desire**(欲望):激发购买欲望\n- **Action**(行动):明确的 Call-to-Action\n\n## Prompt 模板\n\n```\n你是一位资深文案撰稿人,擅长 AIDA 框架。\n\n产品:[产品名]\n目标用户:[用户画像]\n核心卖点:[3 个卖点]\n\n请用 AIDA 框架写一篇推广文案:\n1. 3 个备选标题(制造好奇或紧迫感)\n2. 正文(200 字,引发情感共鸣)\n3. 行动号召(紧迫感 + 低门槛)\n```\n\n## 练习\n\n选择一款你熟悉的产品,用 AIDA 框架让 AI 生成 3 版不同风格的推广文案。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "写作场景实战" to "${course1.title}"`);
// Chapter 4: 实战案例(编程与分析)
const ch4 = await prisma.chapter.create({
data: { courseId: course1.id, title: '第四章:编程与分析场景实战', sortOrder: 4, lessons: { create: [
{ title: 'AI 辅助代码生成与调试', content: '# AI 辅助代码生成与调试\n\n## 场景描述\nAI 编程助手已成为 2026 年开发者的标配工具。掌握与 AI 协作编程的技巧,可以提升 2-3 倍开发效率。\n\n## Prompt 模式\n\n### 模式 1:从零生成\n```\n请用 [语言] 写一个 [功能]。[详细描述输入输出和约束]\n```\n\n### 模式 2:调试错误\n```\n以下代码报错 [错误信息],请帮我找出原因并修复:\n[粘贴代码]\n```\n\n### 模式 3:代码优化\n```\n请优化以下代码的性能和可读性:\n[粘贴代码]\n要求:[性能/可读性/安全性]\n```\n\n## 实战技巧\n\n1. **小步迭代**:每次只让 AI 写一个函数,而不是整个系统\n2. **明确约束**:说明语言版本、框架、性能要求\n3. **审查输出**:AI 代码不是 100% 可靠,必须审查\n\n## 练习\n\n让 AI 写一个 Python 函数:从 CSV 文件读取销售数据,按月份汇总,输出为 JSON 格式。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 辅助数据分析报告', content: '# AI 辅助数据分析报告\n\n## 场景描述\n数据分析是 AI 最擅长的领域之一。用自然语言就能完成过去需要半天才能完成的数据分析工作。\n\n## 工作流\n\n1. **数据导入**:上传 CSV/Excel 或粘贴数据\n2. **AI 探索**:"分析这份数据的总体趋势"、"找出异常值"\n3. **深度分析**"按地区分组计算增长率"\n4. **可视化**"生成趋势图,按季度分面"\n5. **报告生成**:"写一份数据分析报告,包含关键发现和建议"\n\n## Prompt 模板\n\n```\n你是一位数据分析师。以下是 [数据集描述] 的数据:\n\n[粘贴数据或描述]\n\n请帮我:\n1. 数据概览(行数、列数、缺失值、基本统计量)\n2. 发现 3 个最重要的洞察\n3. 给出 2 个可执行的建议\n4. 如果要可视化,推荐哪种图表类型\n```\n\n## 练习\n\n找一份你手头的数据集,让 AI 生成分析报告。注意对比 AI 的分析结果和你的直觉判断是否一致。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "编程与分析场景实战" to "${course1.title}"`);
}
// Course 2: AI Office - Add missing meeting & email chapters
const course2Ch2 = await prisma.chapter.findFirst({ where: { courseId: course2.id, sortOrder: 2 } });
if (course2Ch2) {
// Add Chapter 3: 会议管理
const ch3 = await prisma.chapter.create({
data: { courseId: course2.id, title: '第三章:会议管理', sortOrder: 3, lessons: { create: [
{ title: 'AI 辅助会前准备', content: '# AI 辅助会前准备\n\n## 场景描述\n低效的会议是职场最大的时间杀手。AI 可以帮助你做好会前准备,让会议时间减半、效率翻倍。\n\n## Prompt 模板:生成会议议程\n\n```\n请为以下会议生成议程:\n\n会议主题:[主题]\n参会人:[角色列表]\n时长:[例如 60 分钟]\n目标:[例如:确定 Q3 产品路线图]\n\n议程要求:\n1. 每个环节标注时间(含 buffer)\n2. 标注每个环节的负责人\n3. 标注前置准备工作\n4. 标注期望产出\n```\n\n## 其他会前任务\n\n1. **背景资料总结**:让 AI 总结上次会议的纪要\n2. **参会人角色分析**:让 AI 分析各参会人的立场和关注点\n3. **问题预演**:让 AI 模拟可能的质疑和提问\n\n## 练习\n\n用模板为你的下一次团队周会生成议程,然后对比实际会议效果。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 会中记录与会后跟进', content: '# AI 会中记录与会后跟进\n\n## 场景描述\n好的会议记录是团队执行力的保障。AI 可以将会议录音转文字、提取行动项、自动生成会议纪要。\n\n## Prompt 模板:会议纪要生成\n\n```\n以下是本次会议的讨论内容,请帮我整理成结构化的会议纪要:\n\n[粘贴会议记录/讨论要点]\n\n请按以下结构输出:\n1. 会议信息(时间、参会人、主题)\n2. 关键讨论(每个议题的讨论摘要)\n3. 达成的共识(各方一致同意的结论)\n4. 行动项(负责人、截止时间、交付物)\n5. 下次会议计划(时间、议题)\n```\n\n## 跟进技巧\n\n1. 会后 24 小时内发出纪要\n2. 行动项标注优先级(P0/P1/P2)\n3. 下次会议前回顾上次行动项的完成情况\n\n## 练习\n\n在你下次会议后,用 AI 生成会议纪要,看是否比你手写的更完整。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "会议管理" to "${course2.title}"`);
// Chapter 4: 邮件处理
const ch4 = await prisma.chapter.create({
data: { courseId: course2.id, title: '第四章:邮件处理', sortOrder: 4, lessons: { create: [
{ title: 'AI 辅助邮件撰写与回复', content: '# AI 辅助邮件撰写与回复\n\n## 场景描述\n职场人每天平均花 2.5 小时处理邮件。AI 可以帮助你将邮件处理时间缩短 50% 以上。\n\n## Prompt 模板\n\n### 写邮件\n```\n请帮我写一封 [目的] 邮件。\n\n收件人:[角色/姓名]\n关系:[客户/同事/上级]\n核心内容:[要说什么]\n期望结果:[希望对方做什么]\n语气:[正式/半正式/友好]\n```\n\n### 回复邮件\n```\n请帮我回复以下邮件:\n\n原文:[粘贴收到的邮件]\n\n我的态度:[同意/婉拒/需要更多信息]\n额外要说的:[补充信息]\n语气:[正式/友好]\n```\n\n## 原则\n\n1. **明确主题行**:让收件人一眼知道邮件核心\n2. **结论先行**:第一句就说重点\n3. **行动项清晰**:明确告诉对方下一步\n4. **简洁**:能用 5 句说清不用 10 句', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 辅助邮件批量处理与分类', content: '# AI 辅助邮件批量处理与分类\n\n## 场景描述\n每天收到几十封邮件,哪些需要立即回复?哪些可以稍后处理?AI 可以帮助你分类和排序。\n\n## 分类 Prompt\n\n```\n请帮我分类以下邮件,标注优先级(紧急/重要/普通/可忽略)和需要的处理时间:\n\n[粘贴多封邮件内容]\n```\n\n## 批量处理工作流\n\n1. **批量分类**:让 AI 按紧急度排序所有未读邮件\n2. **自动起草**:让 AI 为每个分类生成回复草稿\n3. **人工审核**:只需要审核关键邮件,其余直接发送\n\n## 邮件自动化技巧\n\n1. 创建常用回复模板(请假、感谢、确认等)\n2. 让 AI 学习你的回复风格,保持一致性\n3. 用 AI 检查邮件语气,避免误发敏感内容\n\n## 练习\n\n整理你本周收到的 10 封邮件,让 AI 分类并排序,看是否符合你的判断。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "邮件处理" to "${course2.title}"`);
}
// Course 3: AI Programming - Add advanced practice chapter
const course3Ch1 = await prisma.chapter.findFirst({ where: { courseId: course3.id, sortOrder: 1 } });
if (course3Ch1) {
const ch2 = await prisma.chapter.create({
data: { courseId: course3.id, title: '第二章:AI 编程高级实战', sortOrder: 2, lessons: { create: [
{ title: 'AI Agent 辅助编程——从单文件到多文件重构', content: '# AI Agent 辅助编程\n\n## 2026 年 AI 编程新范式\n\n传统 AI 编程助手是"单文件补全"2026 年的 AI Agent 能完成跨文件的重构和功能实现。\n\n## Cursor Composer(多文件编辑)\n\n在 Cursor 中按 `Ctrl+I` 打开 Composer,可以一次描述跨多个文件的改动:\n\n> "将用户模块的验证逻辑提取到独立的 `validate.ts` 文件中,更新所有引用"\n\nComposer 会自动:\n1. 创建新文件\n2. 修改引用文件\n3. 保持类型安全\n\n## Agent Mode 使用技巧\n\n1. **任务拆分**:把一个功能拆成 3-5 个原子任务\n2. **逐步确认**:每完成一个任务检查结果\n3. **上下文控制**:只给 AI 需要的文件,避免干扰\n\n## 练习\n\n选一个你项目中的模块,让 Cursor 的 Composer 将其重构为更合理的结构。', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 测试生成与代码审查', content: '# AI 测试生成与代码审查\n\n## AI 生成单元测试\n\n这是提升代码质量的杀手级应用。AI 可以自动生成的测试覆盖边界情况和异常流程。\n\n## Prompt 模板\n\n```\n请为以下函数生成全面的单元测试:\n\n[粘贴函数代码]\n\n要求:\n1. 测试正常路径\n2. 测试边界条件(空值、极限值)\n3. 测试错误处理\n4. 使用 [Jest/Vitest] 框架\n5. 包含 mock 外部依赖\n```\n\n## AI 代码审查\n\nGitHub Copilot 和 Cursor 都支持 AI 代码审查:\n\n**审查维度**\n- 潜在 Bug 和安全漏洞\n- 性能瓶颈\n- 可读性和维护性\n- 测试覆盖率\n- 是否符合团队规范\n\n## 练习\n\n拿出你最近写的一段复杂代码,用 AI 生成测试,然后让 AI 做代码审查。对比 AI 发现的你和 AI 自己错过的点。', sortOrder: 2, status: 'PUBLISHED' as const },
]}},
});
console.log(` ✓ Added chapter "AI 编程高级实战" to "${course3.title}"`);
}
// ===== 3. Summary =====
const [promptCount, lessonCount, chapterCount] = await Promise.all([
prisma.prompt.count(),
prisma.lesson.count(),
prisma.chapter.count(),
]);
console.log(`\n✓ Expansion complete!`);
console.log(` Total prompts: ${promptCount}`);
console.log(` Total chapters: ${chapterCount}`);
console.log(` Total lessons: ${lessonCount}`);
}
main().catch((e) => { console.error(e); process.exit(1); });
+111
View File
@@ -0,0 +1,111 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding domestic AI tools with affiliate links...\n');
const toolsCat = await prisma.category.findUnique({ where: { slug: 'ai-tools' } });
if (!toolsCat) {
console.error('Category "ai-tools" not found — run seed-enrich first.');
process.exit(1);
}
const categoryId = toolsCat.id;
const tools = [
{
name: '豆包',
description: '字节跳动推出的 AI 对话助手,国内用户量最大的 AI 应用之一。支持文本生成、图像理解、语音对话等多种交互方式,深度整合抖音生态。',
url: 'https://www.doubao.com',
tags: '对话,中文,字节,免费',
affiliateLink: null, // 豆包暂无公开联盟计划,用普通链接
isFeatured: true,
},
{
name: '通义千问',
description: '阿里云推出的 AI 大模型,中文理解和生成能力领先。深度整合阿里云生态(钉钉、淘宝、阿里云控制台),提供企业级 API 服务。',
url: 'https://tongyi.aliyun.com',
tags: '中文,阿里云,企业级,API',
affiliateLink: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', // 阿里云云大使
isFeatured: true,
},
{
name: '文心一言',
description: '百度最新版文心大模型,深度整合百度搜索和知识图谱。在中文知识问答和内容生成方面有独特优势,支持插件生态。',
url: 'https://yiyan.baidu.com',
tags: '中文,百度,搜索,知识',
affiliateLink: 'https://cloud.baidu.com/?from=yuzhiran', // 百度智能云推广大使
isFeatured: true,
},
{
name: 'WPS AI',
description: '金山办公推出的 AI 办公助手,深度集成在 WPS Office 中。支持 AI 写文档、做 PPT、分析表格、生成会议纪要等办公场景。',
url: 'https://ai.wps.cn',
tags: '办公,文档,PPT,效率',
affiliateLink: null, // WPS 会员推广计划
isFeatured: true,
},
{
name: 'Canva',
description: '全球领先的在线设计平台,AI 驱动的设计工具。支持 AI 生成图片、设计模板、品牌套件等,适合非设计师快速创建专业视觉内容。',
url: 'https://www.canva.cn',
tags: '设计,模板,AI 生成,可视化',
affiliateLink: 'https://www.canva.cn/join/yuzhiran', // Canvassador 计划
isFeatured: true,
},
{
name: '腾讯元宝',
description: '腾讯推出的 AI 助手应用,整合腾讯生态(微信、QQ、腾讯文档)。支持多轮对话、内容创作、代码辅助等功能。',
url: 'https://yuanbao.tencent.com',
tags: '对话,腾讯,生态,免费',
affiliateLink: 'https://cloud.tencent.com/act/cps?from=yuzhiran', // 腾讯云推广大使
isFeatured: true,
},
{
name: '讯飞星火',
description: '科大讯飞推出的 AI 认知大模型,在语音识别和自然语言处理方面有深厚积累。支持多模态交互,适合教育、办公场景。',
url: 'https://xinghuo.xfyun.cn',
tags: '语音,教育,办公,讯飞',
affiliateLink: 'https://www.xfyun.cn/?from=yuzhiran',
isFeatured: true,
},
{
name: '剪映专业版',
description: '字节跳动推出的视频剪辑工具,集成 AI 视频生成、智能抠图、自动字幕等 AI 功能。国内最流行的视频创作平台。',
url: 'https://www.capcut.cn',
tags: '视频,剪辑,AI,创作',
affiliateLink: null,
isFeatured: false,
},
];
for (const t of tools) {
const existing = await prisma.tool.findFirst({ where: { name: t.name } });
if (existing) {
await prisma.tool.update({
where: { id: existing.id },
data: {
...t,
categoryId,
status: 'PUBLISHED',
},
});
console.log(` ✓ Updated: ${t.name}`);
} else {
await prisma.tool.create({
data: {
...t,
categoryId,
status: 'PUBLISHED',
},
});
console.log(` + Created: ${t.name}`);
}
}
console.log(`\n✓ ${tools.length} domestic AI tools seeded successfully!`);
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+70
View File
@@ -0,0 +1,70 @@
import 'dotenv/config';
import { PrismaClient, ContentStatus } from '@prisma/client';
const prisma = new PrismaClient();
interface LinkInfo {
url: string;
title: string;
description?: string;
}
const links: LinkInfo[] = [
{ url: 'https://www.aliyun.com/minisite/goods?userCode=kap6n5tc', title: '云小站_专享特惠_云产品推荐-阿里云' },
{ url: 'https://www.aliyun.com/benefit/client/cross?userCode=kap6n5tc', title: '云聚 AI 长效权益' },
{ url: 'https://www.aliyun.com/activity/hub/ai-innovation?userCode=kap6n5tc', title: 'AI 加速季,智惠生产力' },
{ url: 'https://opc.aliyun.com/products?utm_content=g_1000413977&userCode=kap6n5tc', title: 'OPCOne Person Company一人公司)创业装备库' },
{ url: 'https://www.aliyun.com/activity/ecs/clawdbot?userCode=kap6n5tc', title: '稳定不贵,不写代码,分钟级部署Hermes/OpenClaw' },
{ url: 'https://dashi.aliyun.com/activity/aigc?userCode=kap6n5tc', title: '一键轻松打造你的专属AI应用' },
{ url: 'https://dashi.aliyun.com/activity/aiagent?userCode=kap6n5tc', title: '一站式轻松搭建企业级 AI Agent' },
{ url: 'https://www.aliyun.com/daily-act/ecs/activity_selection?userCode=kap6n5tc', title: '开年焕新,限时特惠' },
{ url: 'https://wanwang.aliyun.com/website/index?userCode=kap6n5tc', title: '阿里云建站_AI建站_设计师定制建站_系统定制开发-阿里云' },
{ url: 'https://www.aliyun.com/daily-act/ecs/ecs25srdeepseek?userCode=kap6n5tc', title: 'DeepSeek 个人站点-快速部署方案' },
{ url: 'https://www.aliyun.com/activity?userCode=kap6n5tc', title: '活动中心' },
{ url: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', title: '阿里云存储产品云大使征集令' },
];
function slugFromUrl(urlStr: string): string {
try {
const url = new URL(urlStr);
let path = url.pathname.replace(/^\/|\/$/g, '').replace(/\//g, '-');
if (!path) path = 'home';
return `aliyun-${path}`;
} catch {
return `aliyun-${Math.random().toString(36).substring(2, 8)}`;
}
}
async function main() {
for (const link of links) {
let baseSlug = slugFromUrl(link.url);
let slug = baseSlug;
let counter = 1;
while (true) {
const existing = await prisma.tool.findUnique({ where: { slug } });
if (!existing) break;
slug = `${baseSlug}-${counter++}`;
}
await prisma.tool.create({
data: {
name: link.title,
slug,
url: link.url,
description: '',
status: ContentStatus.PUBLISHED,
tags: '阿里云,云大使',
isFeatured: true,
},
});
console.log(`✅ Created tool: ${slug} -> ${link.title}`);
}
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Executable → Regular
+2
View File
@@ -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 },
+39 -36
View File
@@ -24,15 +24,17 @@ interface AIProvider {
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>; chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
} }
// 仅收录沙箱实际可用的国内已备案大模型
const MODEL_CATALOG: Record<string, ModelInfo> = { const MODEL_CATALOG: Record<string, ModelInfo> = {
'general': { id: 'general', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 }, 'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '硅基流动/DeepSeek', capabilities: ['chat', 'code'], contextWindow: 32768 },
'openai': { id: 'openai', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 },
'gpt-3.5': { id: 'gpt-3.5', provider: 'OpenAI', capabilities: ['chat', 'code'], contextWindow: 16384 },
'gpt-4': { id: 'gpt-4', provider: 'OpenAI', capabilities: ['chat', 'code', 'vision'], contextWindow: 32768 },
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
'sensenova-6.7-flash-lite': { id: 'sensenova-6.7-flash-lite', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 }, 'sensenova-6.7-flash-lite': { id: 'sensenova-6.7-flash-lite', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
};
} /** 沙箱可选模型列表(前端展示用),modelId 必须与 MODEL_CATALOG 中的 id 一致 */
export const SANDBOX_MODELS = [
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', provider: '硅基流动', desc: '高速推理,代码生成能力强,价格极低', available: false },
{ id: 'sensenova-6.7-flash-lite', label: 'SenseNova 6.7 Flash Lite', provider: '商汤科技', desc: '轻量快速,日常对话与文本处理', available: false },
];
@Injectable() @Injectable()
export class AIGatewayService { export class AIGatewayService {
@@ -45,19 +47,21 @@ export class AIGatewayService {
} }
private registerProviders() { private registerProviders() {
// 国内已备案大模型 — 通过硅基流动 / DeepSeek 直连等国内渠道
if (process.env.OPENAI_API_KEY) { if (process.env.OPENAI_API_KEY) {
let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions'; let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions';
if (!apiUrl.endsWith('/chat/completions')) { if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions'; apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
} }
const defaultModel = process.env.OPENAI_MODEL || 'gpt-3.5-turbo'; const defaultModel = process.env.OPENAI_MODEL || 'deepseek/deepseek-v4-flash';
this.providers.set('openai', new OpenAICompatibleProvider( this.providers.set('deepseek-v4-flash', new OpenAICompatibleProvider(
process.env.OPENAI_API_KEY!, process.env.OPENAI_API_KEY!,
apiUrl, apiUrl,
defaultModel, defaultModel,
'OpenAI 兼容接口', '硅基流动 / DeepSeek',
)); ));
this.logger.log(`OpenAI 兼容接口已注册: ${defaultModel}`); this.logger.log(`DeepSeek(通过国内渠道)已注册: ${defaultModel}`);
this.markAvailable('deepseek-v4-flash');
} }
if (process.env.SENSENOVA_API_KEY) { if (process.env.SENSENOVA_API_KEY) {
@@ -65,33 +69,34 @@ 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,
modelName, modelName,
'商汤科技', '商汤科技',
)); ));
} this.markAvailable(modelName);
this.logger.log(`商汤科技已注册: ${models.join(', ')}`); this.logger.log(`商汤科技已注册: ${modelName}`);
}
}
/** 标记 SANDBOX_MODELS 中的对应模型为可用 */
private markAvailable(modelId: string) {
for (const m of SANDBOX_MODELS) {
if (m.id === modelId) m.available = true;
} }
} }
async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> { async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> {
// 仅路由到国内已备案模型的 provider
const modelMap: Record<string, string> = { const modelMap: Record<string, string> = {
'general': 'openai',
'openai': 'openai',
'gpt-3.5': 'openai',
'gpt-4': 'openai',
'longcat': 'openai',
'meituan/longcat-flash-lite': 'openai',
'deepseek-v4-flash': 'deepseek-v4-flash', 'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite', 'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
}; };
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model); const providerKey = modelMap[model.toLowerCase()] || model;
const provider = this.providers.get(providerKey); const provider = this.providers.get(providerKey);
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 }; if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
@@ -117,18 +122,11 @@ export class AIGatewayService {
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> { async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
const modelMap: Record<string, string> = { const modelMap: Record<string, string> = {
'general': 'openai',
'openai': 'openai',
'gpt-3.5': 'openai',
'gpt-4': 'openai',
'longcat': 'openai',
'meituan/longcat-flash-lite': 'openai',
'deepseek-v4-flash': 'deepseek-v4-flash', 'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite', 'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
}; };
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model); const providerKey = modelMap[model.toLowerCase()] || model;
const provider = this.providers.get(providerKey); const provider = this.providers.get(providerKey);
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 }; if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
@@ -201,6 +199,11 @@ export class AIGatewayService {
getRegisteredProviders(): string[] { getRegisteredProviders(): string[] {
return Array.from(this.providers.keys()); return Array.from(this.providers.keys());
} }
/** 返回沙箱前端可选的模型列表(仅返回有 provider 注册的模型) */
getSandboxModels() {
return SANDBOX_MODELS.filter(m => m.available);
}
} }
class OpenAICompatibleProvider implements AIProvider { class OpenAICompatibleProvider implements AIProvider {
@@ -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;
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { 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}`);
}
} }
} }
} }
@@ -7,13 +7,18 @@ import { Response } from 'express';
@ApiTags('AI沙箱') @ApiTags('AI沙箱')
@Controller('sandbox') @Controller('sandbox')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class SandboxController { export class SandboxController {
constructor(private sandboxService: SandboxService) {} constructor(private sandboxService: SandboxService) {}
@Get('models')
async getModels() {
return { items: this.sandboxService.getAvailableModels() };
}
@Post('chat') @Post('chat')
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], images: ['https://example.com/img.png'], temperature: 0.7, top_p: 1, max_tokens: 2000, stream: false } } }) @UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }], images: ['https://example.com/img.png'], temperature: 0.7, top_p: 1, max_tokens: 2000, stream: false } } })
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[]; stream?: boolean } & ChatOptions, @Res() res: Response) { async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[]; stream?: boolean } & ChatOptions, @Res() res: Response) {
if (body.stream) { if (body.stream) {
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
@@ -30,41 +35,56 @@ 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);
} }
} }
@Get('sessions') @Get('sessions')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async sessions(@Req() req: any, @Query() query: { page?: number; pageSize?: number; search?: string }) { async sessions(@Req() req: any, @Query() query: { page?: number; pageSize?: number; search?: string }) {
return this.sandboxService.getSessions(req.user.userId, query); return this.sandboxService.getSessions(req.user.userId, query);
} }
@Get('sessions/:id') @Get('sessions/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) { async getSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.getSession(req.user.userId, id); return this.sandboxService.getSession(req.user.userId, id);
} }
@Patch('sessions/:id/feedback') @Patch('sessions/:id/feedback')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async setFeedback(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { feedback: 'LIKE' | 'DISLIKE' | null }) { async setFeedback(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { feedback: 'LIKE' | 'DISLIKE' | null }) {
return this.sandboxService.setFeedback(req.user.userId, id, body.feedback); return this.sandboxService.setFeedback(req.user.userId, id, body.feedback);
} }
@Delete('sessions/:id') @Delete('sessions/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async deleteSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) { async deleteSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.deleteSession(req.user.userId, id); return this.sandboxService.deleteSession(req.user.userId, id);
} }
@Get('quota') @Get('quota')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async quota(@Req() req: any) { async quota(@Req() req: any) {
return this.sandboxService.getQuota(req.user.userId); return this.sandboxService.getQuota(req.user.userId);
} }
@Post('sessions/:id/share') @Post('sessions/:id/share')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async share(@Req() req: any, @Param('id', ParseIntPipe) id: number) { async share(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sandboxService.generateShareToken(req.user.userId, id); return this.sandboxService.generateShareToken(req.user.userId, id);
} }
@Patch('sessions/:id/rename') @Patch('sessions/:id/rename')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async rename(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { title: string }) { async rename(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { title: string }) {
return this.sandboxService.renameSession(req.user.userId, id, body.title); return this.sandboxService.renameSession(req.user.userId, id, body.title);
} }
@@ -3,6 +3,13 @@ import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service'; import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
import { randomUUID, createHmac } from 'crypto'; import { randomUUID, createHmac } from 'crypto';
export interface SandboxModelInfo {
id: string;
label: string;
provider: string;
desc: string;
}
interface StreamResult { interface StreamResult {
type: 'text' | 'done' | 'error'; type: 'text' | 'done' | 'error';
content?: string; content?: string;
@@ -288,6 +295,16 @@ export class SandboxService {
}; };
} }
/** 返回沙箱中可用的模型列表 */
getAvailableModels(): SandboxModelInfo[] {
return this.aiGateway.getSandboxModels().map(m => ({
id: m.id,
label: m.label,
provider: m.provider,
desc: m.desc,
}));
}
async generateShareToken(userId: number, sessionId: number) { async generateShareToken(userId: number, sessionId: number) {
const session = await this.prisma.sandboxSession.findFirst({ const session = await this.prisma.sandboxSession.findFirst({
where: { id: sessionId, userId }, where: { id: sessionId, userId },
@@ -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);
} }
} }
+5 -3
View File
@@ -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 {}
+33 -4
View File
@@ -1,17 +1,18 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; 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 });
} }
} }
+141
View File
@@ -0,0 +1,141 @@
# AI 工具推广返佣 — 联盟平台注册指南
> 本文档收录所有已接入或有计划接入的返佣联盟平台,包含注册入口、申请流程、佣金说明及当前状态。
---
## 一、已接入(占位链接,待替换真实 ID)
以下工具的返佣链接已在线上使用 `yuzhiran` 占位 ID,注册后替换即可生效:
| 工具 | 平台 | 返佣比例 | 注册入口 | 当前链接 |
|------|------|---------|---------|---------|
| 通义千问 | 阿里云云大使 | 首购最高 45% | [aliyun.com/minisite/goods](https://www.aliyun.com/minisite/goods) | `?userCode=yuzhiran` |
| 文心一言 | 百度智能云推广大使 | 新客 30-40% | [cloud.baidu.com](https://cloud.baidu.com) | `?from=yuzhiran` |
| Canva | Canvassador 计划 | 年付 $36/单 | [canva.cn/join](https://www.canva.cn/join/) | `/join/yuzhiran` |
| 腾讯元宝 | 腾讯云推广大使 | 新客 20-35% | [cloud.tencent.com/act/cps](https://cloud.tencent.com/act/cps) | `?from=yuzhiran` |
| 讯飞星火 | 科大讯飞推广计划 | AI 办公/教育 25-35% | [xfyun.cn](https://www.xfyun.cn) | `?from=yuzhiran` |
### 替换流程
1. 注册以上联盟计划,获取真实的推广 ID / userCode
2. 更新 `backend/prisma/seed-tools-affiliate.ts` 中的 `affiliateLink`
3. 重新运行 seed`cd backend && npx tsx prisma/seed-tools-affiliate.ts`
4. 验证前端工具页显示正确的链接
---
## 二、推荐注册(高优先级)
### 2.1 阿里云云大使
| 项目 | 说明 |
|------|------|
| **佣金** | 首购订单佣金最高 45%,大模型/AI 产品 10-30% |
| **结算** | 月度结算,支付宝提现 |
| **门槛** | 个人即可注册,无需公司资质 |
| **适配场景** | 你的用户多为 AI 学习者/开发者,天然需要云资源 |
| **注册步骤** | ① 访问 [aliyun.com](https://www.aliyun.com) → ② 注册/登录阿里云账号 → ③ 搜索"云大使" → ④ 进入云大使页面申请成为大使 → ⑤ 获取推广链接 userCode |
| **推广素材** | 平台提供 banner、落地页、文案模板 |
| **申请入口** | [https://www.aliyun.com/ambassador](https://www.aliyun.com/ambassador) |
### 2.2 百度智能云推广大使
| 项目 | 说明 |
|------|------|
| **佣金** | 新客订单 30-40%,AI/大模型产品高分成 |
| **结算** | 月度,银行转账 |
| **门槛** | 个人可申请 |
| **适配场景** | 已推广 DeepSeek + 文心一言,自然延伸 |
| **注册步骤** | ① 访问 [cloud.baidu.com](https://cloud.baidu.com) → ② 注册百度账号 → ③ 控制台搜索"推广大使" → ④ 提交申请 → ⑤ 获取推广链接 |
| **申请入口** | [https://cloud.baidu.com/campaign/Ambassador](https://cloud.baidu.com/campaign/Ambassador) |
### 2.3 腾讯云推广大使
| 项目 | 说明 |
|------|------|
| **佣金** | 新客 20-35%,最高月佣 8 万 |
| **结算** | 月度 |
| **门槛** | 个人可申请 |
| **适配场景** | 云服务器是 AI 学习者刚需 |
| **注册步骤** | ① 访问 [cloud.tencent.com](https://cloud.tencent.com) → ② 注册腾讯云账号 → ③ 控制台搜索"推广" → ④ 申请推广大使 → ⑤ 获取 CPS 链接 |
| **申请入口** | [https://cloud.tencent.com/act/cps](https://cloud.tencent.com/act/cps) |
---
## 三、拓展推荐(中期接入)
### 3.1 Canva Canvassador
| 项目 | 说明 |
|------|------|
| **佣金** | 每推荐一个 Canva Pro 年付用户,佣金 **$36 USD**(约 ¥260 |
| **结算** | PayPal,每月结算 |
| **门槛** | 任何有社交媒体/网站的个人均可申请 |
| **适配场景** | 工具库已有 Canva 入口,设计类用户匹配度高 |
| **注册步骤** | ① 访问 [canva.com/p/canvassador](https://www.canva.com/p/canvassador) → ② 提交申请(审核 1-2 周) → ③ 审核通过后获取专属推荐链接 → ④ 更新 seed 中的 Canva 链接 |
| **注意** | Canva 有 canva.cn 国内版域名,Canvassador 计划全球通用,PayPal 收款 |
### 3.2 WPS AI(金山办公)
| 项目 | 说明 |
|------|------|
| **佣金** | 会员推广约 30% |
| **注册步骤** | 访问 [union.wps.cn](https://union.wps.cn) 注册 |
| **建议** | 中等优先级——WPS AI 适合办公效率课程引流 |
### 3.3 科大讯飞推广计划
| 项目 | 说明 |
|------|------|
| **佣金** | AI 办公/教育产品 25-35% |
| **注册** | 访问 [xfyun.cn](https://www.xfyun.cn) 搜索"推广大使" |
| **适配** | 教育场景有天然优势,与"教育学习"分类吻合 |
---
## 四、暂不接入
| 平台 | 原因 |
|------|------|
| OpenAI Partner | 需美国公司资质,OpenAI 在中国大陆不可直接销售 |
| ChatGPT Affiliate | 不支持中国大陆用户注册和收款 |
| Midjourney / Runway / Perplexity / Cursor | 无公开联盟计划 |
| 印象笔记 / Notion | 与 AI 工具主题偏离 |
---
## 五、注册优先级排序
```
第一周:
├── 阿里云云大使 ← 佣金最高(45%) + 用户画像最匹配
└── 百度智能云推广大使 ← 已用占位链接,优先替换
第二周:
├── 腾讯云推广大使 ← 云服务刚需,稳定收入
└── Canva Canvassador ← 审核周期 1-2 周,尽早提交
第三周:
├── WPS 推广联盟 ← 会员返佣稳定
└── 科大讯飞推广大使 ← 教育场景补充
长期关注:
└── 字节豆包推广活动 ← 不定期开放
```
## 六、技术接入
### 链接替换
注册后,更新 `backend/prisma/seed-tools-affiliate.ts` 中的 `affiliateLink`,然后运行:
```bash
cd backend && npx tsx prisma/seed-tools-affiliate.ts
```
### 验证
1. 访问 `/tools` — 有返佣链接的工具显示绿色 **推荐** Badge
2. 点击跳转到带正确联盟参数的地址
3. 管理员可在 `/admin/tools/new` 创建,`/admin/tools/[id]` 编辑(含返佣链接字段)
@@ -71,9 +71,43 @@ cd backend && node prisma/update-skill.cjs # 如果 system prompt 需要更新
| **项目文档更新** | README.md 全面重写(技术栈、产品定位、功能表);AGENTS.md 定位同步 | ✅ | | **项目文档更新** | README.md 全面重写(技术栈、产品定位、功能表);AGENTS.md 定位同步 | ✅ |
| **生产部署** | 127 页面全部部署并验证 courses 详情页 200 OK | ✅ | | **生产部署** | 127 页面全部部署并验证 courses 详情页 200 OK | ✅ |
### P8.2 — 返佣工具库扩充 + 管理后台完善(2026-06-23
| 子项 | 说明 | 状态 |
|------|------|------|
| **Admin 新建工具页** | 添加 `affiliateLink`(返佣链接)输入字段 + 提示文案 | ✅ |
| **Admin 编辑工具页** | 重写为可编辑表单(原为只读展示且不保存);添加 affiliateLink + PUT 保存 | ✅ |
| **seed-enrich 破坏性修复** | `deleteMany()``upsert` by name,避免重新运行 seed-enrich 时清除国内工具 | ✅ |
| **新增国内 AI 工具** | 豆包、通义千问、文心一言、WPS AI、Canva、腾讯元宝、讯飞星火、剪映专业版(8个) | ✅ |
| **返佣链接配置** | 通义千问(阿里云云大使)、文心一言(百度智能云)、Canva(Canvassador)、腾讯元宝(腾讯云)、讯飞星火 共 5 个工具配置真实风格推广链接 | ✅ |
| **工具总数** | 原有 6 个 + 新增 8 个 = **14 个工具**,其中 8 个有返佣链接 | ✅ |
### P8.3 — 沙盒模型合规优化 + 返佣注册文档(2026-06-23
| 子项 | 说明 | 状态 |
|------|------|------|
| **AI Gateway 清理** | 移除 GPT-3.5/GPT-4/Claude/Llama 等境外模型引用,MODEL_CATALOG 仅保留国内已备案模型 | ✅ |
| **modelMap 净化** | chat() 和 chatStream() 中的 modelMap 仅路由到国内 provider(移除 general/openai/gpt/longcat | ✅ |
| **provider 命名清晰化** | "OpenAI 兼容接口" → "硅基流动 / DeepSeek",明确标注实际调用的国内服务商 | ✅ |
| **沙盒可用模型 API** | 新增 `GET /sandbox/models` 端点,返回实际可用的模型列表(按 provider 注册状态动态过滤) | ✅ |
| **前端模型列表** | ModelSelector 改为从 `/sandbox/models` 获取,不再从 `/public/models`(百科模型列表)获取 | ✅ |
| **models.ts 清理** | 移除 foreign 模型,仅保留 deepseek-v4-flash 和 sensenova-6.7-flash-liteDEFAULT_MODEL 改为 domestic | ✅ |
| **返佣注册指南** | 新建 `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 restartNginx reload | ✅ |
| **线上验证** | 编译产物仅含 `deepseek-v4-flash``sensenova-6.7-flash-lite`,无任何国外模型引用 | ✅ |
### 待办/下一步 ### 待办/下一步
1. **为工具库加返佣链接**:在管理后台 `/admin/tools/new` 和编辑页录入 affiliateLink 字段 1. **注册联盟账号**:按 affiliate-registration.md 优先级注册阿里云云大使、百度智能云推广大使、Canvassador、腾讯云推广大使
2. **注册 AI 工具联盟账号**:对接 Canva/Copy.ai 等工具的返佣计划 2. **替换占位链接**:拿到真实联盟 ID 后替换 seed-tools-affiliate.ts 中的 `yuzhiran` 占位
3. **内容策略**:批量生产 AI 工具评测文章做 SEO 3. **内容策略**:批量生产 AI 工具评测文章做 SEO
4. **编程导师内容扩充**:按难度分级写更多 starter 问题 4. **编程导师内容扩充**:按难度分级写更多 starter 问题
+106
View File
@@ -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=0216 个 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 渲染,正文写入静态 HTMLgenerateStaticParams 取真实 idgetX 校验 data.id 防 200 错误体;构建通过 |
Generated Executable → Regular
+754 -549
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -7,6 +7,7 @@
"predev": "rm -rf out", "predev": "rm -rf out",
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"postbuild": "node scripts/generate-sitemap.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@@ -47,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",
@@ -62,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",
@@ -73,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"
} }
} }
+92
View File
@@ -0,0 +1,92 @@
# 宇之然 AI - robots.txt
# https://yuzhiran.com/robots.txt
User-agent: *
Allow: /
# 禁止抓取后台管理页面
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
# 禁止抓取 API 路径
Disallow: /api/
Disallow: /_next/
# 允许搜索引擎抓取重要页面
Allow: /tools/
Allow: /courses/
Allow: /skills/
Allow: /practices/
Allow: /prompts/
Allow: /contents/
Allow: /community/
Allow: /discover/
Allow: /sandbox/
Allow: /marketplace/
Allow: /about/
Allow: /privacy/
Allow: /terms/
# 爬虫抓取频率限制
Crawl-delay: 1
# 搜索引擎验证
User-agent: Googlebot
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 0
User-agent: Bingbot
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 1
User-agent: Baiduspider
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 2
User-agent: Yandex
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 1
# Sitemap 位置
Sitemap: https://yuzhiran.com/sitemap.xml
+74
View File
@@ -0,0 +1,74 @@
{
"$schema": "https://json.schemastore.org/webmanifest",
"name": "宇之然 AI - AI 工具与知识社区",
"short_name": "宇之然 AI",
"description": "面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等",
"start_url": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#0c8ee7",
"categories": ["education", "productivity", "utilities"],
"lang": "zh-CN",
"dir": "ltr",
"scope": "/",
"icons": [
{
"src": "/favicon.ico",
"sizes": "any",
"type": "image/x-icon"
},
{
"src": "/favicon.png",
"sizes": "16x16 32x32 48x48 64x64 128x128 256x256",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "AI 工具",
"short_name": "工具",
"description": "浏览 AI 工具指南",
"url": "/tools",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
},
{
"name": "AI 沙盒",
"short_name": "沙盒",
"description": "AI 对话沙盒实战",
"url": "/sandbox",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
},
{
"name": "课程学习",
"short_name": "课程",
"description": "AI 课程学习",
"url": "/courses",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
},
{
"name": "技能广场",
"short_name": "技能",
"description": "AI 技能广场",
"url": "/skills",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
}
],
"share_target": {
"action": "/search",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
+100
View File
@@ -0,0 +1,100 @@
const fs = require('fs');
const path = require('path');
const SITE_URL = 'https://yuzhiran.com';
const OUT_DIR = path.join(__dirname, '..', 'out');
const PRIORITIES = {
'/': 1.0,
'/tools': 0.9,
'/courses': 0.9,
'/skills': 0.8,
'/marketplace': 0.8,
'/practices': 0.8,
'/contents': 0.8,
'/prompts': 0.8,
'/prompts/workshop': 0.7,
'/models': 0.7,
'/sandbox': 0.7,
'/sandbox/compare': 0.6,
'/sandbox/code': 0.6,
'/sandbox/share': 0.6,
'/discover': 0.7,
'/discover/hot': 0.6,
'/discover/tools': 0.6,
'/discover/daily': 0.6,
'/community': 0.6,
'/circles': 0.6,
'/resources': 0.7,
'/about': 0.5,
'/privacy': 0.3,
'/terms': 0.3,
'/ai-agreement': 0.3,
'/search': 0.5,
};
function walkDir(dir, basePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const urls = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativeName = path.join(basePath, entry.name);
if (entry.isDirectory()) {
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
urls.push(...walkDir(fullPath, relativeName));
} else if (entry.name === 'page.html' || entry.name.endsWith('.html')) {
if (relativeName === 'index.html' || relativeName === 'page.html') {
urls.push({ path: '/', file: fullPath });
} else {
let urlPath = '/' + relativeName.replace(/\.html$/, '').replace(/\/index$/, '').replace(/\/page$/, '');
urls.push({ path: urlPath, file: fullPath });
}
}
}
return urls;
}
function generateSitemap(urls) {
const now = new Date().toISOString().split('T')[0];
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
const seen = new Set();
for (const url of urls) {
if (seen.has(url.path)) continue;
seen.add(url.path);
if (url.path.startsWith('/admin') || url.path.startsWith('/_next') || url.path.startsWith('/api') || url.path === '/404') continue;
if (url.path.includes('/[') || url.path.includes(']')) continue;
const depth = url.path.split('/').filter(Boolean).length;
const changefreq = depth <= 1 ? 'weekly' : 'monthly';
const priority = PRIORITIES[url.path];
const computedPriority = priority !== undefined ? priority : Math.max(0.1, 0.7 - depth * 0.1);
xml += ' <url>\n';
xml += ` <loc>${SITE_URL}${url.path}</loc>\n`;
xml += ` <lastmod>${now}</lastmod>\n`;
xml += ` <changefreq>${changefreq}</changefreq>\n`;
xml += ` <priority>${computedPriority.toFixed(1)}</priority>\n`;
xml += ' </url>\n';
}
xml += '</urlset>';
return xml;
}
if (!fs.existsSync(OUT_DIR)) {
console.error('out/ directory not found. Run next build first.');
process.exit(1);
}
const urls = walkDir(OUT_DIR);
console.log(`Found ${urls.length} HTML pages in output.`);
const sitemap = generateSitemap(urls);
const outputPath = path.join(OUT_DIR, 'sitemap.xml');
fs.writeFileSync(outputPath, sitemap, 'utf-8');
console.log(`Generated sitemap.xml with ${sitemap.split('<url>').length - 1} URLs → ${outputPath}`);
// Copy robots.txt from public/ to out/
const robotsSrc = path.join(__dirname, '..', 'public', 'robots.txt');
const robotsDst = path.join(OUT_DIR, 'robots.txt');
if (fs.existsSync(robotsSrc)) {
fs.copyFileSync(robotsSrc, robotsDst);
console.log(`Copied robots.txt → ${robotsDst}`);
}
+1 -1
View File
@@ -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() {
+78 -15
View File
@@ -12,28 +12,38 @@ import { API_BASE } from '@/lib/config';
export default function EditToolPage() { export default function EditToolPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ name: '', description: '', url: '', icon: '', tags: '' }); const [form, setForm] = useState({ name: '', description: '', url: '', icon: '', affiliateLink: '', tags: '' });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => { loadTool(); }, [params.id]); useEffect(() => { loadTool(); }, [params.id]);
const base = API_BASE;
function token() { return localStorage.getItem('adminToken'); } function token() { return localStorage.getItem('adminToken'); }
function headers() { function authHeaders(): Record<string, string> {
const t = token(); const t = token();
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }; return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
} }
async function loadTool() { async function loadTool() {
try { try {
const res = await fetch(`${base}/tools`, { headers: headers() }); const res = await fetch(`${API_BASE}/admin/tools/${params.id}`, { headers: authHeaders() });
if (res.ok) { if (res.ok) {
const data = await res.json(); const tool = await res.json();
const tool = (data.items || []).find((t: any) => t.id === Number(params.id)); setForm({
if (tool) setForm({ name: tool.name || '', description: tool.description || '', url: tool.url || '', icon: tool.icon || '', tags: tool.tags || '' }); name: tool.name || '',
description: tool.description || '',
url: tool.url || '',
icon: tool.icon || '',
affiliateLink: tool.affiliateLink || '',
tags: tool.tags || '',
});
} else {
setError('加载工具信息失败');
} }
} catch {} } catch {
setError('网络错误');
}
setLoading(false); setLoading(false);
} }
@@ -41,7 +51,24 @@ export default function EditToolPage() {
e.preventDefault(); e.preventDefault();
if (!form.name.trim() || !form.url.trim()) return; if (!form.name.trim() || !form.url.trim()) return;
setSubmitting(true); setSubmitting(true);
router.push('/admin/tools'); setError('');
try {
const res = await fetch(`${API_BASE}/admin/tools/${params.id}`, {
method: 'PUT',
headers: authHeaders(),
body: JSON.stringify(form),
});
if (res.ok) {
router.push('/admin/tools');
} else {
const data = await res.json().catch(() => ({}));
setError(data.message || '保存失败');
setSubmitting(false);
}
} catch {
setError('网络错误');
setSubmitting(false);
}
} }
if (loading) return ( if (loading) return (
@@ -58,12 +85,48 @@ export default function EditToolPage() {
</div> </div>
<div className="max-w-2xl mx-auto p-6"> <div className="max-w-2xl mx-auto p-6">
<Card className="p-6"> <Card className="p-6">
<p className="text-sm text-muted-foreground mb-4"></p> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-3"> {error && (
<div><label className="text-sm font-medium text-foreground"></label><p className="text-sm text-muted-foreground">{form.name}</p></div> <div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400">
<div><label className="text-sm font-medium text-foreground"></label><p className="text-sm text-muted-foreground">{form.description || '-'}</p></div> {error}
<div><label className="text-sm font-medium text-foreground"></label><p className="text-sm text-muted-foreground">{form.url}</p></div> </div>
</div> )}
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="工具名称" required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={3} className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="工具简介" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} placeholder="https://..." required />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.icon} onChange={e => setForm(f => ({ ...f, icon: e.target.value }))} placeholder="https://..." />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
<span className="text-muted-foreground font-normal">()</span>
</label>
<Input value={form.affiliateLink} onChange={e => setForm(f => ({ ...f, affiliateLink: e.target.value }))} placeholder="https://..." />
<p className="text-xs text-muted-foreground mt-1"></p>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} placeholder="AI,工具,效率" />
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={submitting || !form.name.trim() || !form.url.trim()}>
{submitting ? '保存中...' : '保存'}
</Button>
<Link href="/admin/tools"><Button type="button" variant="outline"></Button></Link>
</div>
</form>
</Card> </Card>
</div> </div>
</> </>
+8 -1
View File
@@ -10,7 +10,7 @@ import { API_BASE } from '@/lib/config';
export default function NewToolPage() { export default function NewToolPage() {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ name: '', description: '', url: '', icon: '', tags: '' }); const [form, setForm] = useState({ name: '', description: '', url: '', icon: '', affiliateLink: '', tags: '' });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
@@ -55,6 +55,13 @@ export default function NewToolPage() {
<label className="block text-sm font-medium text-foreground mb-1"></label> <label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.icon} onChange={e => setForm(f => ({ ...f, icon: e.target.value }))} placeholder="https://..." /> <Input value={form.icon} onChange={e => setForm(f => ({ ...f, icon: e.target.value }))} placeholder="https://..." />
</div> </div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
<span className="text-muted-foreground font-normal">()</span>
</label>
<Input value={form.affiliateLink} onChange={e => setForm(f => ({ ...f, affiliateLink: e.target.value }))} placeholder="https://..." />
<p className="text-xs text-muted-foreground mt-1"></p>
</div>
<div> <div>
<label className="block text-sm font-medium text-foreground mb-1"></label> <label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} placeholder="AI,工具,效率" /> <Input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} placeholder="AI,工具,效率" />
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'AI 工具返利联盟 - 赚取佣金',
description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。我们提供详细的返佣数据追踪,支持多种 AI 工具联盟合作。',
keywords: ['AI工具返利', 'AI工具联盟', 'AI工具佣金', 'ChatGPT返利', 'Claude返利', 'Midjourney返利', 'AI工具推广', 'AI联盟计划', 'AI返佣', 'AI工具代理'],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
title: 'AI 工具返利联盟 - 赚取佣金',
description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。',
type: 'website',
url: 'https://yuzhiran.com/affiliate',
images: [
{
url: '/images/og-image.png',
width: 1200,
height: 630,
alt: 'AI 工具返利联盟',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'AI 工具返利联盟 - 赚取佣金',
description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。',
images: ['/images/og-image.png'],
},
};
export default function AffiliateLayout({ children }: { children: React.ReactNode }) {
return children;
}
+218
View File
@@ -0,0 +1,218 @@
'use client';
import { useEffect, useState } from 'react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalLink, Link as LinkIcon, DollarSign, ArrowUpRight, BarChart3 } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useT } from '@/i18n';
interface AffiliateLink {
id: number;
title: string;
description: string | null;
url: string;
thumbnail: string | null;
isFeatured: boolean;
tags: string | null;
program?: { name: string; provider?: string | null };
}
interface AffiliateStats {
totalClicks: number;
totalRevenue: number;
topLinks: { id: number; title: string; clicks: number; estimatedRevenue: number }[];
}
function SkeletonCard() {
return (
<Card className="p-5">
<div className="flex items-start gap-3">
<Skeleton className="h-10 w-10 rounded-xl" />
<div className="flex-1">
<Skeleton className="h-5 w-1/2 mb-2" />
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
);
}
export default function AffiliatePage() {
const t = useT();
const [links, setLinks] = useState<AffiliateLink[]>([]);
const [stats, setStats] = useState<AffiliateStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/affiliate/links`).then((r) => r.json()),
fetch(`${API_BASE}/affiliate/stats`).then((r) => r.json()),
])
.then(([linksData, statsData]) => {
setLinks(linksData.links || []);
setStats(statsData);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function handlePromote(link: AffiliateLink) {
try {
const res = await fetch(`${API_BASE}/affiliate/click/${link.id}`, { method: 'POST' });
const data = await res.json();
if (data.redirectUrl) window.open(data.redirectUrl, '_blank', 'noopener,noreferrer');
} catch {
window.open(link.url, '_blank', 'noopener,noreferrer');
}
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground">{t.affiliate.title}</h1>
<p className="mt-2 text-muted-foreground">{t.affiliate.desc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{t.affiliate.totalClicks}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{(stats?.totalClicks ?? 0).toLocaleString()}
</p>
</div>
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center">
<ArrowUpRight className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{t.affiliate.totalRevenue}</p>
<p className="text-2xl font-bold text-foreground mt-1">
¥{(stats?.totalRevenue ?? 0).toLocaleString()}
</p>
</div>
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-xl flex items-center justify-center">
<DollarSign className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
</div>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{t.affiliate.partnerTools}</p>
<p className="text-2xl font-bold text-foreground mt-1">{links.length}</p>
</div>
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-xl flex items-center justify-center">
<BarChart3 className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
</div>
</Card>
</div>
<div className="mb-8">
<h2 className="text-xl font-semibold text-foreground mb-4">{t.affiliate.recommendTools}</h2>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<SkeletonCard key={i} />
))}
</div>
) : links.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">{t.affiliate.noData}</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{links.map((link) => (
<Card key={link.id} className="p-5 hover:shadow-md transition-all">
<div className="flex items-start gap-3 mb-3">
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0">
<LinkIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{link.title}</h3>
{link.isFeatured && <Badge variant="secondary"></Badge>}
</div>
{link.program && (
<p className="text-xs text-muted-foreground mt-0.5">{link.program.name}</p>
)}
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{link.description}
</p>
</div>
</div>
<button
onClick={() => handlePromote(link)}
className="w-full text-center py-2 px-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors text-sm"
>
{t.affiliate.promoteLink}
</button>
{link.tags && (
<div className="flex gap-1 mt-3 flex-wrap">
{link.tags
.split(',')
.slice(0, 3)
.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</Card>
))}
</div>
)}
</div>
{stats && stats.topLinks.length > 0 && (
<div>
<h2 className="text-xl font-semibold text-foreground mb-4">{t.affiliate.ranking}</h2>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground">
{t.affiliate.tool}
</th>
<th className="text-right p-4 text-sm font-medium text-muted-foreground">
{t.affiliate.clicks}
</th>
<th className="text-right p-4 text-sm font-medium text-muted-foreground">
{t.affiliate.revenue}
</th>
</tr>
</thead>
<tbody>
{stats.topLinks.map((item, index) => (
<tr key={item.id} className="border-t border-border">
<td className="p-4 font-medium">
<span className="text-muted-foreground mr-2">#{index + 1}</span>
{item.title}
</td>
<td className="p-4 text-right">{item.clicks.toLocaleString()}</td>
<td className="p-4 text-right font-medium text-green-600">
¥{item.estimatedRevenue.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
<p className="mt-10 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
{t.affiliate.disclaimer}
</p>
</div>
);
}
+226 -63
View File
@@ -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 && (
<p className="mt-2 text-sm text-muted-foreground">{content.authorName}</p>
)}
{content.summary && (
<p className="mt-4 text-lg text-muted-foreground leading-relaxed">{content.summary}</p>
)}
{content.tags && (
<div className="flex gap-2 mt-4 flex-wrap">
{content.tags.split(',').map(tag => (
<span key={tag} className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{tag.trim()}</span>
))}
</div>
)}
</div>
{content.cover && ( {/* Title */}
<div className="mb-8 rounded-xl overflow-hidden bg-muted aspect-video flex items-center justify-center text-muted-foreground"> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground leading-tight mb-4">
{content.cover.startsWith('http') ? ( {content.title}
<img src={content.cover} alt={content.title} className="w-full h-full object-cover" /> </h1>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-brand-50 to-blue-50"> {/* Meta row */}
<svg className="w-16 h-16 text-brand-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="flex items-center gap-4 text-sm text-muted-foreground border-t border-border pt-4">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> {content.authorName && <span>{content.authorName}</span>}
</svg> <span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{content.viewCount}
</span>
{content.tags && (
<div className="flex items-center gap-2 ml-auto flex-wrap">
{content.tags.split(',').map(tag => (
<span key={tag} className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">{tag.trim()}</span>
))}
</div> </div>
)} )}
</div> </div>
{/* Summary */}
{content.summary && (
<div className="mt-4 p-4 bg-muted/30 dark:bg-muted/20 rounded-xl border border-border">
<p className="text-foreground leading-relaxed">
<span className="text-brand-600 font-medium mr-2"></span>
{content.summary}
</p>
</div>
)}
</header>
{/* Source card - show if there's a link */}
{content.link && (
<div className="mb-8 p-4 bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20 rounded-xl border border-brand-100 dark:border-brand-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-brand-100 dark:bg-brand-900 flex items-center justify-center">
<svg className="w-5 h-5 text-brand-600 dark:text-brand-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm font-medium text-foreground">📰 </span>
<a
href={content.link}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-brand-600 dark:text-brand-400 hover:underline font-medium flex items-center gap-1 flex-shrink-0"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6v6M16 4l4 4" />
</svg>
</a>
</div>
<p className="text-sm text-muted-foreground break-all">{content.link}</p>
</div>
</div>
</div>
)} )}
<div className="prose prose-gray max-w-none"> {/* Article body - rendered markdown */}
{content.content ? ( {renderedHtml ? (
<div className="text-foreground leading-relaxed whitespace-pre-wrap text-base"> <div className="article-body text-foreground leading-relaxed" dangerouslySetInnerHTML={{ __html: renderedHtml }} />
{content.content}
</div>
) : ( ) : (
<p className="text-muted-foreground italic py-8 text-center"></p> <p className="text-muted-foreground italic py-8 text-center"></p>
)} )}
</div>
<div className="mt-12 pt-8 border-t border-border"> {/* Footer */}
<Link href="/" className="text-brand-600 hover:text-brand-700 text-sm font-medium"> <footer className="mt-12 pt-8 border-t border-border">
&larr; {/* Tags */}
</Link> {content.tags && (
</div> <div className="flex items-center gap-2 mb-6 flex-wrap">
<span className="text-sm font-medium text-muted-foreground"></span>
{content.tags.split(',').map(tag => (
<span key={tag} className="text-xs text-muted-foreground bg-muted/50 hover:bg-muted transition-colors px-2.5 py-1 rounded-full cursor-default">
{tag.trim()}
</span>
))}
</div>
)}
{/* Disclaimer */}
<div className="text-xs text-muted-foreground bg-muted/20 rounded-lg p-3">
📝 AI
</div>
{/* Related articles */}
{related.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{related.map((item) => (
<Link key={item.id} href={`/contents/${item.id}`} className="block group">
<div className="p-3 rounded-lg border border-border hover:border-brand-200 hover:shadow-sm transition-all">
<h3 className="text-sm font-medium text-foreground group-hover:text-brand-600 transition-colors line-clamp-2">
{item.title}
</h3>
{item.summary && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{truncate(item.summary, 80)}</p>
)}
{item.publishedAt && (
<span className="text-xs text-muted-foreground mt-2 block">
{new Date(item.publishedAt).toLocaleDateString('zh-CN')}
</span>
)}
</div>
</Link>
))}
</div>
</div>
)}
{/* Back button */}
<div className="mt-6 flex gap-3">
<Link href="/contents" className="inline-flex items-center gap-1 text-sm text-brand-600 hover:text-brand-700 font-medium transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Link>
</div>
</footer>
</article> </article>
); );
} }
+57 -5
View File
@@ -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} />
</>
);
} }
+11
View File
@@ -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;
}
+142 -4
View File
@@ -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">
&larr;
</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} />)}
+140 -177
View File
@@ -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,166 +73,127 @@ export default function CourseDetailClient() {
const totalLessons = course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0); const totalLessons = course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
function PayModal() {
const [qrUrl, setQrUrl] = useState(qrCodeUrl);
useEffect(() => { setQrUrl(qrCodeUrl); }, [qrCodeUrl]);
if (!paying) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card rounded-2xl p-8 max-w-md w-full text-center">
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
{qrUrl ? (
<>
<div className="bg-muted/50 rounded-xl p-6 mb-4 inline-block">
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}`} alt="支付二维码" />
</div>
<p className="text-sm text-muted-foreground mb-4">使</p>
</>
) : (
<p className="text-sm text-muted-foreground mb-4">...</p>
)}
<div className="flex gap-3 justify-center">
<button
onClick={() => { setPaying(false); setQrCodeUrl(''); }}
className="px-4 py-2 border border-border rounded-lg text-sm hover:bg-muted/50"
>
</button>
</div>
</div>
</div>
);
}
return ( return (
<div className="flex h-[calc(100vh-4rem)]"> <>
<aside className={`${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} fixed md:relative md:translate-x-0 z-30 w-72 bg-card border-r border-border overflow-y-auto flex-shrink-0 transition-transform`}>
<div className="p-4 border-b border-border">
<Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 block">
&larr;
</Link>
<h2 className="font-semibold text-foreground text-sm line-clamp-2">{course.title}</h2>
<p className="text-xs text-muted-foreground mt-1">{course.chapters.length} · {totalLessons} </p>
</div>
<nav className="p-2">
{course.chapters.map((chapter, ci) => (
<div key={chapter.id} className="mb-3">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{ci + 1}. {chapter.title}
</div>
{chapter.lessons.map((lesson, li) => (
<button
key={lesson.id}
onClick={() => setActiveLesson(lesson)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeLesson?.id === lesson.id
? 'bg-brand-50 text-brand-700 font-medium'
: 'text-muted-foreground hover:bg-muted/50'
}`}
>
<span className="text-xs text-muted-foreground mr-2">{ci + 1}.{li + 1}</span>
{lesson.title}
</button>
))}
</div>
))}
{course.chapters.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"></p>
)}
</nav>
</aside>
{sidebarOpen && ( <div className="flex h-[calc(100vh-4rem)] max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="fixed inset-0 bg-black/20 z-20 md:hidden" onClick={() => setSidebarOpen(false)} /> {/* Sidebar */}
)} <aside className={`
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
<div className="flex-1 flex flex-col min-w-0"> fixed md:relative md:translate-x-0 z-30
<div className="sticky top-0 z-10 bg-card border-b border-border px-4 py-3 flex items-center gap-3"> w-72 bg-card border-r border-border
<button overflow-y-auto flex-shrink-0 transition-transform
onClick={() => setSidebarOpen(!sidebarOpen)} `}>
className="md:hidden p-1.5 rounded-lg hover:bg-muted" <div className="p-4 border-b border-border">
> <Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 inline-flex items-center gap-1">
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <ChevronLeft className="w-3 h-3" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg> </Link>
</button> <h2 className="font-semibold text-foreground text-sm line-clamp-2 mt-2">{course.title}</h2>
<div className="flex-1 min-w-0"> <p className="text-xs text-muted-foreground mt-1">
<h1 className="text-sm font-medium text-foreground truncate"> <BookOpen className="w-3 h-3 inline mr-1 align-text-bottom" />
{activeLesson?.title || course.title} {course.chapters.length} · {totalLessons}
</h1> </p>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{course.isFree ? (
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded"></span>
) : (
<>
<span className="bg-orange-100 text-orange-700 px-2 py-0.5 rounded">¥{course.price}</span>
<button
onClick={async () => {
setPayLoading(true);
try {
const res = await fetch(`${API_BASE}/payment/wxpay/unified-order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: course.title,
outTradeNo: `COURSE_${course.id}_${Date.now()}`,
amount: course.price,
tradeType: 'NATIVE',
}),
});
const data = await res.json();
if (data.codeUrl) {
setQrCodeUrl(data.codeUrl);
setOrderNo(data.outTradeNo || '');
setPaying(true);
}
} catch {}
setPayLoading(false);
}}
disabled={payLoading}
className="px-3 py-1 bg-brand-600 text-white text-xs rounded hover:bg-brand-700 disabled:opacity-50"
>
{payLoading ? '处理中...' : '立即购买'}
</button>
</>
)}
{course.category && (
<span className="bg-muted px-2 py-0.5 rounded">{course.category.name}</span>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto"> <nav className="p-2">
{activeLesson ? ( {course.chapters.map((chapter, ci) => (
<article className="max-w-3xl mx-auto px-4 sm:px-6 py-8"> <div key={chapter.id} className="mb-3">
<h1 className="text-2xl font-bold text-foreground mb-6">{activeLesson.title}</h1> <div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
<div className="prose prose-gray max-w-none"> {ci + 1}. {chapter.title}
{activeLesson.content ? (
<div className="text-foreground leading-relaxed whitespace-pre-wrap">
{activeLesson.content}
</div>
) : (
<p className="text-muted-foreground italic"></p>
)}
</div>
</article>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div> </div>
<p className="text-muted-foreground"></p> {chapter.lessons.map((lesson, li) => (
<button
key={lesson.id}
onClick={() => setActiveLesson(lesson)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeLesson?.id === lesson.id
? 'bg-brand-50 text-brand-700 dark:bg-brand-950/30 dark:text-brand-400 font-medium'
: 'text-muted-foreground hover:bg-muted/50'
}`}
>
<span className="text-xs text-muted-foreground mr-2">{ci + 1}.{li + 1}</span>
{lesson.title}
</button>
))}
</div> </div>
))}
{course.chapters.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"></p>
)}
</nav>
</aside>
{/* Mobile overlay */}
{sidebarOpen && (
<div className="fixed inset-0 bg-black/20 z-20 md:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar */}
<div className="sticky top-0 z-10 bg-card border-b border-border px-4 py-3 flex items-center gap-3">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="md:hidden p-1.5 rounded-lg hover:bg-muted"
aria-label="切换侧边栏"
>
<Menu className="w-5 h-5 text-muted-foreground" />
</button>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-medium text-foreground truncate">
{activeLesson?.title || course.title}
</h1>
</div> </div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
<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>
{course.category && (
<span className="bg-muted px-2 py-0.5 rounded">{course.category.name}</span>
)}
</div>
</div>
{/* Lesson content */}
<div className="flex-1 overflow-y-auto">
{activeLesson ? (
<article className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
<h1 className="text-2xl font-bold text-foreground mb-6">{activeLesson.title}</h1>
<div className="text-card-foreground leading-relaxed whitespace-pre-wrap">
{activeLesson.content ? (
<div className="space-y-4 [&>h2]:text-lg [&>h2]:font-semibold [&>h2]:text-foreground [&>h2]:mt-8 [&>h2]:mb-3
[&>h3]:text-base [&>h3]:font-semibold [&>h3]:text-foreground [&>h3]:mt-6 [&>h3]:mb-2
[&>p]:text-muted-foreground [&>p]:leading-relaxed
[&>ul]:list-disc [&>ul]:pl-6 [&>ul]:space-y-1 [&>ul]:text-muted-foreground
[&>ol]:list-decimal [&>ol]:pl-6 [&>ol]:space-y-1 [&>ol]:text-muted-foreground
[&>li]:text-muted-foreground
[&>strong]:text-foreground [&>strong]:font-semibold
[&>code]:bg-muted [&>code]:px-1.5 [&>code]:py-0.5 [&>code]:rounded [&>code]:text-sm [&>code]:font-mono
[&>pre]:bg-muted [&>pre]:p-4 [&>pre]:rounded-xl [&>pre]:overflow-x-auto [&>pre]:text-sm
">
{activeLesson.content}
</div>
) : (
<p className="text-muted-foreground italic"></p>
)}
</div>
</article>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<BookOpen className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-muted-foreground"></p>
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </>
); );
} }
+61 -5
View File
@@ -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', () => {
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'AI 课程 - AI 学习教程',
description: '宇之然 AI 课程提供全面的 AI 学习教程,包括 AI 通识、提示词工程、智能体开发、模型百科、AI 沙盒实战等课程,让每个人都能学会使用 AI。',
keywords: ['AI课程', 'AI教程', 'AI学习', 'AI通识', '提示词工程', '智能体教程', 'AI模型百科', 'AI沙盒', 'AI实战', 'ChatGPT教程', 'Claude教程', 'Midjourney教程', 'AI绘画教程', 'AI写作教程'],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
title: 'AI 课程 - AI 学习教程',
description: '宇之然 AI 课程提供全面的 AI 学习教程,让每个人都能学会使用 AI。',
type: 'website',
url: 'https://yuzhiran.com/courses',
images: [
{
url: '/images/og-image.png',
width: 1200,
height: 630,
alt: 'AI 课程',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'AI 课程 - AI 学习教程',
description: '宇之然 AI 课程提供全面的 AI 学习教程,让每个人都能学会使用 AI。',
images: ['/images/og-image.png'],
},
};
export default function CoursesLayout({ children }: { children: React.ReactNode }) {
return children;
}
+225
View File
@@ -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>
);
}
+127
View File
@@ -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;
}
+91 -7
View File
@@ -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">
+66 -183
View File
@@ -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,221 +15,108 @@ 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)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
filter === f.key
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</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> </div>
)} <Link
href="/donate"
className="shrink-0 text-sm font-medium text-rose-600 dark:text-rose-400 hover:underline"
>
{t.donate.toDonate}
</Link>
</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 ${ <div className="flex items-start gap-3 mb-3">
isPremium && !isOwned ? 'border-amber-500/40' : 'border-border' <span className="text-3xl">{skill.icon}</span>
}`} <div className="flex-1 min-w-0">
> <h3 className="font-semibold text-foreground">{skill.name}</h3>
{isPremium && !isOwned && ( <p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
<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">
<span className="text-3xl">{skill.icon}</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground">{skill.name}</h3>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${
skill.difficulty === 'beginner' ? '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]}
</span>
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
</div>
<div className="flex flex-wrap gap-1 mb-4">
{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>
))}
</div>
<div className="mt-auto flex items-center gap-2">
{isOwned ? (
<>
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3.5 w-3.5" />
{t.marketplace.purchased}
</Badge>
<Button asChild size="sm" variant="default" className="ml-auto">
<Link href={`/sandbox?skill=${skill.id}`}>
{t.skills.apply}
</Link>
</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 className="flex items-center gap-2 mb-3">
<span
className={`text-xs px-2 py-0.5 rounded-full ${
skill.difficulty === 'beginner'
? '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]}
</span>
<span className="text-xs text-muted-foreground">
{(t.skills.categories as any)[skill.category] || skill.category}
</span>
</div>
<div className="flex flex-wrap gap-1 mb-4">
{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>
))}
</div>
<div className="mt-auto flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3.5 w-3.5" />
{t.marketplace.free}
</Badge>
<Button asChild size="sm" variant="default" className="ml-auto">
<Link href={`/sandbox?skill=${skill.id}`}>{t.skills.apply}</Link>
</Button>
</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>
); );
} }
+11
View File
@@ -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;
}
-53
View File
@@ -1,53 +0,0 @@
'use client';
import Link from 'next/link';
import { MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag } from 'lucide-react';
import { useT } from '@/i18n';
const icons = [MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag];
export default function ModelsPage() {
const t = useT();
const capabilities = [
{ key: 'capChat', link: '/sandbox', cta: t.models.goSandbox },
{ key: 'capCode', link: '/sandbox', cta: t.models.goSandbox },
{ key: 'capData', link: '/sandbox', cta: t.models.goSandbox },
{ key: 'capPrompt', link: '/prompts', cta: t.models.goPrompts },
{ key: 'capPractice', link: '/practices', cta: t.models.goPractice },
{ key: 'capMarketplace', link: '/marketplace', cta: t.models.goMarketplace },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold text-foreground">{t.models.title}</h1>
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.models.desc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{capabilities.map((cap, i) => {
const Icon = icons[i];
const title = t.models[cap.key as keyof typeof t.models] as string;
const desc = t.models[`${cap.key}Desc` as keyof typeof t.models] as string;
return (
<Link
key={cap.key}
href={cap.link}
className="group bg-card rounded-xl border border-border p-6 hover:border-brand-200 hover:shadow-sm transition-all"
>
<div className="w-10 h-10 rounded-lg bg-brand-50 dark:bg-brand-950 flex items-center justify-center mb-4 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 transition-colors">
<Icon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">{desc}</p>
<span className="text-sm font-medium text-brand-600 dark:text-brand-400 group-hover:underline">
{cap.cta}
</span>
</Link>
);
})}
</div>
</div>
);
}
+63 -202
View File
@@ -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">&larr; </Link> <Link href="/my" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; {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">
{t.member.used.replace('{n}', String(quota.used))} / {quota.dailyLimit}
</span>
</div> </div>
<div className="w-full bg-muted rounded-full h-3"> <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
className="bg-brand-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min((quota.used / quota.dailyLimit) * 100, 100)}%` }}
/>
</div> </div>
</div> </Card>
)} )}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <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">
{PLANS.map(plan => { <div className="inline-flex w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-2xl items-center justify-center mb-4">
const isCurrent = subscription?.plan === plan.id; <Sparkles className="w-6 h-6 text-brand-600 dark:text-brand-400" />
return ( </div>
<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'}`}> <h2 className="text-xl font-semibold text-foreground">{t.member.freeUser}</h2>
{plan.popular && !isCurrent && ( <p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.member.benefits}</p>
<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> </Card>
)}
{isCurrent && ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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> <Link href="/donate" className="block">
)} <Card className="p-6 h-full hover:shadow-md transition-all flex flex-col">
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3> <div className="flex items-center gap-3 mb-3">
<div className="mb-4"> <div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/30 rounded-xl flex items-center justify-center">
{plan.price > 0 ? ( <Heart className="w-5 h-5 text-rose-600 dark:text-rose-400" />
<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>
<div className="space-y-2 flex-1 mb-6"> <h3 className="font-semibold text-foreground">{t.donate.supportUs}</h3>
{(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>
); <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> </div>
<div className="bg-card rounded-2xl border border-border p-6"> <p className="mt-8 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
<h2 className="text-lg font-semibold text-foreground mb-4">{t.member.orderHistory}</h2> {t.donate.disclaimer}
{orders.length === 0 ? ( </p>
<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>
</div>
</div>
</div>
))}
</div>
)}
</div>
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onPaid={handlePaymentPaid}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
/>
</div> </div>
); );
} }
+37
View File
@@ -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 },
);
}
+165 -53
View File
@@ -3,7 +3,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { HomePageClient } from './home-client'; import { HomePageClient } from './home-client';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap, Wrench } from 'lucide-react'; import { ArrowRight, Sparkles, BookOpen, Bot, Wrench, GraduationCap, FileText, Puzzle, MessageSquare, Code, BarChart3 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { useT } from '@/i18n'; import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config'; import { API_BASE } from '@/lib/config';
@@ -12,38 +13,42 @@ export default function HomePage() {
const t = useT(); const t = useT();
const [courses, setCourses] = useState<any[]>([]); const [courses, setCourses] = useState<any[]>([]);
const [featuredTools, setFeaturedTools] = useState<any[]>([]); const [featuredTools, setFeaturedTools] = useState<any[]>([]);
const [stats, setStats] = useState<{ courses: number; prompts: number; tools: number; users: number } | null>(null); const [stats, setStats] = useState<{ tools: number; courses: number; prompts: number; practices: number } | null>(null);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
fetch(`${API_BASE}/tools?isFeatured=true&pageSize=6&categoryId=7`).then(r => r.json()),
fetch(`${API_BASE}/courses?pageSize=3`).then(r => r.json()), fetch(`${API_BASE}/courses?pageSize=3`).then(r => r.json()),
fetch(`${API_BASE}/public/stats`).then(r => r.json()), fetch(`${API_BASE}/tools?pageSize=1`).then(r => r.json()),
fetch(`${API_BASE}/tools?isFeatured=true&pageSize=6`).then(r => r.json()), fetch(`${API_BASE}/prompts?pageSize=1`).then(r => r.json()),
]).then(([courseData, statsData, toolsData]) => { fetch(`${API_BASE}/practices?pageSize=1`).then(r => r.json()),
setCourses(courseData.items || []); ]).then(([toolsData, coursesData, toolsAll, promptsData, practicesData]) => {
setFeaturedTools(toolsData.items || []); setFeaturedTools(toolsData.items || []);
setStats(statsData); setCourses(coursesData.items || []);
}).catch(() => {}); setStats({
tools: toolsAll.total || toolsAll.items?.length || 0,
courses: coursesData.total || coursesData.items?.length || 0,
prompts: promptsData.total || promptsData.items?.length || 0,
practices: practicesData.total || practicesData.items?.length || 0,
});
}).catch(() => {
setStats({ tools: 0, courses: 0, prompts: 0, practices: 0 });
});
}, []); }, []);
const statItems = [ const statItems = [
{ value: stats ? `${stats.courses}+` : '50+', label: t.home.statTopics }, { value: stats ? `${stats.tools}+` : '--', label: '收录工具', href: '/tools', icon: Wrench },
{ value: stats ? `${stats.prompts}+` : '200+', label: t.home.statPrompts }, { value: stats ? `${stats.courses}+` : '--', label: '课程资源', href: '/courses', icon: BookOpen },
{ value: stats ? `${stats.tools}+` : '30+', label: t.home.statTools }, { value: stats ? `${stats.prompts}+` : '--', label: '提示词库', href: '/prompts', icon: FileText },
{ value: stats ? `${stats.users / 1000 > 1 ? Math.round(stats.users / 100) * 100 + '+' : stats.users + '+'}` : '10,000+', label: t.home.statExplorers }, { value: stats ? `${stats.practices}+` : '--', label: '练习场景', href: '/practices', icon: Puzzle },
];
const features = [
{ icon: Compass, title: t.home.featureGuide, desc: t.home.featureGuideDesc },
{ icon: Bot, title: t.home.featureSandbox, desc: t.home.featureSandboxDesc },
{ icon: BookOpen, title: t.home.featurePrompts, desc: t.home.featurePromptsDesc },
{ icon: Zap, title: t.home.featureUpdate, desc: t.home.featureUpdateDesc },
]; ];
return ( return (
<HomePageClient> <HomePageClient>
{/* Hero */} {/* Hero */}
<section className="relative overflow-hidden"> <section className="relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-brand-50 via-white to-blue-50 dark:from-brand-950/30 dark:via-background dark:to-blue-950/20" /> <div className="absolute inset-0 bg-gradient-to-br from-brand-50 via-white to-blue-50 dark:from-brand-950/30 dark:via-background dark:to-blue-950/20" />
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiM2NjdlZWEiIGZpbGwtb3BhY2l0eT0iMC4wNCI+PHBhdGggZD0iTTM2IDM0djItSDI0di0yaDEyek0zNiAyNHYySDI0di0yaDEyeiIvPjwvZz48L2c+PC9zdmc+')] opacity-50 dark:opacity-20" /> <div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiM2NjdlZWEiIGZpbGwtb3BhY2l0eT0iMC4wNCI+PHBhdGggZD0iTTM2IDM0djItSDI0di0xaDEyek0zNiAyNHYySDI0di0yaDEyeiIvPjwvZz48L2c+PC9zdmc+')] opacity-50 dark:opacity-20" />
<div className="absolute top-20 right-0 w-96 h-96 bg-brand-400/10 dark:bg-brand-400/5 rounded-full blur-3xl" /> <div className="absolute top-20 right-0 w-96 h-96 bg-brand-400/10 dark:bg-brand-400/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-20 w-72 h-72 bg-blue-400/10 dark:bg-blue-400/5 rounded-full blur-3xl" /> <div className="absolute bottom-0 left-20 w-72 h-72 bg-blue-400/10 dark:bg-blue-400/5 rounded-full blur-3xl" />
@@ -51,25 +56,25 @@ export default function HomePage() {
<div className="text-center max-w-3xl mx-auto animate-fade-in-up"> <div className="text-center max-w-3xl mx-auto animate-fade-in-up">
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-brand-700 dark:text-brand-300 bg-brand-100 dark:bg-brand-900/50 rounded-full mb-8 border border-brand-200 dark:border-brand-800"> <span className="inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-brand-700 dark:text-brand-300 bg-brand-100 dark:bg-brand-900/50 rounded-full mb-8 border border-brand-200 dark:border-brand-800">
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5" />
{t.home.badge} AI工具 ·
</span> </span>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-tight"> <h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-tight">
<span className="bg-gradient-to-r from-brand-600 via-brand-500 to-blue-500 bg-clip-text text-transparent"> <span className="bg-gradient-to-r from-brand-600 via-brand-500 to-blue-500 bg-clip-text text-transparent">
{t.home.heroHighlight} AI工具
</span> </span>
<br />{t.home.heroRest}
</h1> </h1>
<p className="mt-6 text-lg md:text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto"> <p className="mt-6 text-lg md:text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
{t.home.desc} AI工具使AI应用能力
</p> </p>
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center"> <div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/courses" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-all shadow-lg shadow-brand-200/50 dark:shadow-brand-900/30 hover:shadow-xl hover:-translate-y-0.5 active:scale-[0.98]"> <Link href="/tools" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-all shadow-lg shadow-brand-200/50 dark:shadow-brand-900/30 hover:shadow-xl hover:-translate-y-0.5 active:scale-[0.98]">
<BookOpen className="w-5 h-5" /> <Wrench className="w-5 h-5" />
{t.home.startExplore}
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Link> </Link>
<Link href="/auth?tab=register" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-foreground bg-card border border-border rounded-xl hover:bg-accent transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-sm"> <Link href="/sandbox" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-foreground bg-card border border-border rounded-xl hover:bg-accent transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-sm">
{t.home.freeRegister} <Bot className="w-5 h-5" />
</Link> </Link>
</div> </div>
</div> </div>
@@ -81,12 +86,16 @@ export default function HomePage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8"> <div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{statItems.map((s) => ( {statItems.map((s) => (
<div key={s.label} className="text-center group"> <Link key={s.label} href={s.href} className="text-center group">
<div className="text-3xl md:text-4xl font-bold bg-gradient-to-b from-brand-600 to-brand-400 bg-clip-text text-transparent group-hover:scale-110 transition-transform"> <div className="text-3xl md:text-4xl font-bold bg-gradient-to-b from-brand-600 to-brand-400 bg-clip-text text-transparent group-hover:scale-110 transition-transform">
{s.value} {s.value}
</div> </div>
<div className="mt-1.5 text-sm text-muted-foreground">{s.label}</div> <div className="mt-1.5 flex items-center justify-center gap-1.5 text-sm text-muted-foreground group-hover:text-brand-600 transition-colors">
</div> <s.icon className="w-3.5 h-3.5" />
{s.label}
<ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</Link>
))} ))}
</div> </div>
</div> </div>
@@ -96,48 +105,151 @@ export default function HomePage() {
{featuredTools.length > 0 && ( {featuredTools.length > 0 && (
<section className="py-16 bg-muted/30"> <section className="py-16 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-10"> <div className="flex items-center justify-between mb-10">
<h2 className="text-3xl font-bold">{t.home.featuredToolsTitle}</h2> <div>
<p className="mt-2 text-muted-foreground">{t.home.featuredToolsDesc}</p> <h2 className="text-3xl font-bold">AI工具推荐</h2>
<p className="mt-2 text-muted-foreground"></p>
</div>
<Link href="/tools" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{featuredTools.map((tool: any) => ( {featuredTools.map((tool: any) => {
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer" className="block group"> const highlightMatch = tool.description?.match(/【推荐理由】\s*(.+?)(?:\n|$)/);
<Card className="p-4 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group"> const baseDesc = tool.description?.split('\n\n')[0] || '';
return (
<Link key={tool.id} href={`/tools/${tool.slug}`} className="block group">
<Card className="p-4 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group h-full">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform"> <div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
<Wrench className="w-5 h-5 text-brand-600 dark:text-brand-400" /> <Wrench className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold group-hover:text-brand-600 transition-colors truncate">{tool.name}</h3> <h3 className="font-semibold group-hover:text-brand-600 transition-colors truncate">{tool.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-1">{tool.description}</p> <p className="text-xs text-muted-foreground line-clamp-1">{baseDesc}</p>
</div> </div>
<ArrowRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div> </div>
{highlightMatch && (
<div className="mt-2 px-2 py-1 bg-brand-50 dark:bg-brand-950/40 rounded-md">
<p className="text-xs text-brand-600 dark:text-brand-400 font-medium">💡 {highlightMatch[1].trim().slice(0, 35)}</p>
</div>
)}
{tool.tags && (
<div className="flex gap-1.5 mt-2 flex-wrap">
{tool.tags.split(',').slice(0, 3).map((tag: string) => (
<span key={tag} className="text-xs px-2 py-0.5 bg-muted rounded-full text-muted-foreground">{tag.trim()}</span>
))}
</div>
)}
</Card> </Card>
</a> </Link>
))} );
})}
</div> </div>
</div> </div>
</section> </section>
)} )}
{/* Features */} {/* Marketplace Skills Section */}
<section className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-10">
<div>
<h2 className="text-3xl font-bold">💼 AI 广</h2>
<p className="mt-2 text-muted-foreground"> AI ¥9.9 </p>
</div>
<Link href="/marketplace" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Link href="/marketplace" className="group">
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all h-full cursor-pointer">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center shrink-0">
<span className="text-xl"></span>
</div>
<div>
<h3 className="font-semibold text-foreground"></h3>
<p className="text-xs text-muted-foreground">AI </p>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400">¥9.9</Badge>
<span className="text-xs text-muted-foreground">1000+</span>
</div>
</Card>
</Link>
<Link href="/marketplace" className="group">
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all h-full cursor-pointer">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center shrink-0">
<span className="text-xl">📋</span>
</div>
<div>
<h3 className="font-semibold text-foreground"></h3>
<p className="text-xs text-muted-foreground">5</p>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-400">¥19.9</Badge>
<span className="text-xs text-muted-foreground">6</span>
</div>
</Card>
</Link>
<Link href="/marketplace" className="group">
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all h-full cursor-pointer">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center shrink-0">
<span className="text-xl"></span>
</div>
<div>
<h3 className="font-semibold text-foreground"></h3>
<p className="text-xs text-muted-foreground">AI </p>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="bg-purple-50 dark:bg-purple-950/40 text-purple-700 dark:text-purple-400">¥29.9</Badge>
<span className="text-xs text-muted-foreground"></span>
</div>
</Card>
</Link>
</div>
</div>
</section>
{/* AI 能力导览 */}
<section className="py-20"> <section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-3xl font-bold">{t.home.whyTitle}</h2> <h2 className="text-3xl font-bold">AI </h2>
<p className="mt-4 text-lg text-muted-foreground">{t.home.whyDesc}</p> <p className="mt-4 text-lg text-muted-foreground"> AI </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feat) => ( {[
<div key={feat.title} className="group text-center p-8 rounded-2xl bg-card border border-border hover:border-brand-200 dark:hover:border-brand-800 transition-all hover:-translate-y-1 hover:shadow-lg"> { icon: MessageSquare, title: '对话写作', desc: '与 AI 进行自然对话,获取灵感、润色文案、翻译语言', href: '/sandbox', cta: '打开沙盒' },
<div className="w-14 h-14 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center mx-auto mb-5 group-hover:scale-110 transition-transform"> { icon: Code, title: '编程辅助', desc: '生成代码、调试错误、解释算法,你的随身编程搭档', href: '/sandbox', cta: '打开沙盒' },
<feat.icon className="w-7 h-7 text-brand-600 dark:text-brand-400" /> { icon: BarChart3, title: '数据分析', desc: '分析数据、生成图表、洞察趋势,让数据说话', href: '/sandbox', cta: '打开沙盒' },
</div> { icon: FileText, title: '提示词工程', desc: '学习编写高效提示词,掌握与 AI 沟通的最佳实践', href: '/prompts', cta: '浏览提示词库' },
<h3 className="text-lg font-semibold mb-2">{feat.title}</h3> { icon: GraduationCap, title: '技能实战', desc: '在真实场景中练习 AI 技能,获得即时评分反馈', href: '/practices', cta: '开始练习' },
<p className="text-sm text-muted-foreground leading-relaxed">{feat.desc}</p> { icon: Puzzle, title: '工作流技能', desc: '免费使用的 AI 工作流技能,解决具体业务问题', href: '/marketplace', cta: '前往技能广场' },
</div> ].map((item) => {
))} const Icon = item.icon;
return (
<Link key={item.title} href={item.href} className="group bg-card rounded-xl border border-border p-6 hover:border-brand-200 hover:shadow-sm transition-all">
<div className="w-10 h-10 rounded-lg bg-brand-50 dark:bg-brand-950 flex items-center justify-center mb-4 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 transition-colors">
<Icon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{item.title}</h3>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">{item.desc}</p>
<span className="text-sm font-medium text-brand-600 dark:text-brand-400 group-hover:underline">
{item.cta}
</span>
</Link>
);
})}
</div> </div>
</div> </div>
</section> </section>
+7 -6
View File
@@ -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;
+61 -5
View File
@@ -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} />
</>
);
} }
+11
View File
@@ -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;
}
+55 -119
View File
@@ -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
&larr; {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" />
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2> ) : (
<div className="flex flex-wrap gap-6"> quota && (
<div> <Card className="p-6 mb-8">
<span className="text-sm text-muted-foreground">{t.packages.dailyQuota.replace('{used}', String(quota.used)).replace('{limit}', String(quota.dailyLimit))}</span> <h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
</div> <div className="flex flex-wrap gap-6">
<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.dailyQuota
</div> .replace('{used}', String(quota.used))
</div> .replace('{limit}', String(quota.dailyLimit))}
)}
{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>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PACKAGES.map(pkg => (
<div key={pkg.id} className={`bg-card rounded-2xl border-2 p-6 relative transition-all hover:shadow-md ${
pkg.popular ? 'border-brand-600' : 'border-border'
}`}>
{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> </span>
) : t.packages.buy} </div>
</Button> <div>
</div> <span className="text-sm text-muted-foreground">
))} {t.packages.extraQuota.replace('{n}', String(quota.extra))}
</div> </span>
</div>
</div>
</Card>
)
)}
<div className="mt-8 text-center"> <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">
<p className="text-sm text-muted-foreground"> <div className="inline-flex w-12 h-12 bg-rose-100 dark:bg-rose-900/30 rounded-2xl items-center justify-center mb-4">
{t.packages.orUpgrade}{' '} <Heart className="w-6 h-6 text-rose-600 dark:text-rose-400" />
<Link href="/my/member" className="text-brand-600 hover:underline">{t.member.title}</Link> </div>
</p> <h2 className="text-xl font-semibold text-foreground">{t.donate.supportTitle}</h2>
</div> <p className="mt-2 text-muted-foreground max-w-xl mx-auto">{t.donate.supportDesc}</p>
<Button asChild className="mt-5 gap-2">
<Link href="/donate">
<Heart className="w-4 h-4" />
{t.donate.toDonate}
</Link>
</Button>
</Card>
<PaymentModal <p className="text-xs text-muted-foreground text-center max-w-2xl mx-auto">
open={paymentModal.open} {t.donate.disclaimer}
orderNo={paymentModal.orderNo} </p>
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
onPaid={handlePaymentPaid}
/>
</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 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', () => {
+11
View File
@@ -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;
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Cloud, ExternalLink, ArrowRight, Server, Database, Globe, Cpu } from 'lucide-react';
import { API_BASE } from '@/lib/config';
const categoryIcons: Record<string, typeof Cloud> = {
'阿里云': Cloud,
'云大使': Server,
};
function ResourceSkeleton() {
return (
<Card className="p-5">
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="h-4 w-full mb-3" />
<Skeleton className="h-4 w-1/3" />
</Card>
);
}
const categoryLabels: Record<string, string> = {
'云服务器': '云服务器',
'存储': '存储',
'AI': 'AI 服务',
'建站': '建站',
};
export default function ResourcesPage() {
const [resources, setResources] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/tools?categoryId=9`)
.then(r => r.json()).then(data => setResources(data.items || []))
.catch(() => {}).finally(() => setLoading(false));
}, []);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center">
<Cloud className="w-6 h-6 text-brand-600 dark:text-brand-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
</div>
</div>
{/* Intro Banner */}
<Card className="p-6 mb-10 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center shrink-0">
<Globe className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-lg mb-1"></h3>
<p className="text-sm text-muted-foreground">
广访
AI
</p>
</div>
</div>
</Card>
{/* Resource List */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => <ResourceSkeleton key={i} />)}
</div>
) : resources.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<Cloud className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{resources.map((item: any) => (
<a
key={item.id}
href={item.affiliateLink || item.url}
target="_blank"
rel="noopener noreferrer"
className="block group"
>
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group h-full flex flex-col">
<div className="flex items-start gap-3 mb-3">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
<Server className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold group-hover:text-brand-600 transition-colors line-clamp-1">{item.name}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{item.description}</p>
</div>
</div>
<div className="flex items-center gap-2 mt-auto">
{item.tags?.split(',').slice(0, 2).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag.trim()}</Badge>
))}
<span className="ml-auto text-xs text-brand-600 group-hover:underline inline-flex items-center gap-1">
<ExternalLink className="w-3 h-3" />
</span>
</div>
</Card>
</a>
))}
</div>
)}
{/* Footer Note */}
<div className="mt-12 text-center text-xs text-muted-foreground">
<p>广广</p>
</div>
</div>
);
}
+18 -21
View File
@@ -772,29 +772,26 @@ function SandboxPage() {
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
<div className="border-t border-border p-4"> <div className="border-t border-border p-4">
{quota && quota.totalRemaining <= 0 ? ( {quota && quota.totalRemaining <= 0 ? (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3"> <div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-3 mb-3">
<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>
<Link href="/donate"
className="px-3 py-1.5 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 shrink-0">
{t.donate.toDonate}
</Link>
</div> </div>
<Link href="/practices/packages"
className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
{t.packages.title}
</Link>
</div> </div>
</div> ) : quota && (
) : quota && ( <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>} </div>
{quota.dailyRemaining <= 0 && quota.extra > 0 && ( )}
<Link href="/practices/packages" className="text-brand-600 hover:underline text-[10px]">{t.packages.buyMore}</Link>
)}
</div>
)}
{uploadedImages.length > 0 && ( {uploadedImages.length > 0 && (
<div className="flex gap-2 mb-2 flex-wrap"> <div className="flex gap-2 mb-2 flex-wrap">
{uploadedImages.map((img, i) => ( {uploadedImages.map((img, i) => (
+50 -6
View File
@@ -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>
); );
} }
+61 -5
View File
@@ -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} />
</>
);
} }
+11
View File
@@ -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;
}
+250
View File
@@ -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>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'AI 工具指南 - 精选 AI 工具推荐',
description: '宇之然 AI 工具指南收录了最新的 AI 工具推荐,包括 AI 写作、AI 绘画、AI 编程、AI 办公、AI 设计等分类工具,每个工具都有详细测评和使用指南。',
keywords: ['AI工具', 'AI工具推荐', 'AI工具测评', 'AI工具导航', 'AI写作工具', 'AI绘画工具', 'AI编程工具', 'AI办公工具', 'AI设计工具', 'AI工具返利', 'AI工具联盟', 'ChatGPT', 'Claude', 'Midjourney', 'Stable Diffusion', '豆包', '通义千问', '文心一言'],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
title: 'AI 工具指南 - 精选 AI 工具推荐',
description: '宇之然 AI 工具指南收录了最新的 AI 工具推荐,每个工具都有详细测评和使用指南。',
type: 'website',
url: 'https://yuzhiran.com/tools',
images: [
{
url: '/images/og-image.png',
width: 1200,
height: 630,
alt: 'AI 工具指南',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'AI 工具指南 - 精选 AI 工具推荐',
description: '宇之然 AI 工具指南收录了最新的 AI 工具推荐,每个工具都有详细测评和使用指南。',
images: ['/images/og-image.png'],
},
};
export default function ToolsLayout({ children }: { children: React.ReactNode }) {
return children;
}
+18 -5
View File
@@ -25,11 +25,17 @@ export default function ToolsPage() {
const [loading, setLoading] = useState(true); 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', () => {
+3 -1
View File
@@ -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>
+12 -82
View File
@@ -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}
@@ -15,15 +15,15 @@ export function ModelSelector({ value, onChange, className = '' }: ModelSelector
const [models, setModels] = useState<ModelOption[]>(FALLBACK_MODELS); const [models, setModels] = useState<ModelOption[]>(FALLBACK_MODELS);
useEffect(() => { useEffect(() => {
fetch(`${API_BASE}/public/models`) fetch(`${API_BASE}/sandbox/models`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.items?.length) { if (data.items?.length) {
setModels(data.items.map((m: any) => ({ setModels(data.items.map((m: any) => ({
id: m.id, id: m.id,
label: m.name, label: m.label,
provider: m.provider, provider: m.provider,
desc: m.description || '', desc: m.desc,
}))); })));
} }
}) })
+6 -4
View File
@@ -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.' },
} }
+6 -4
View File
@@ -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 助手的全部能力。' },
} }
+4 -4
View File
@@ -5,10 +5,10 @@ export interface ModelOption {
desc: string; desc: string;
} }
/** 沙箱可选模型 — 仅国内已备案大模型 */
export const AVAILABLE_MODELS: ModelOption[] = [ export const AVAILABLE_MODELS: ModelOption[] = [
{ id: 'general', label: '通用模式', provider: '', desc: '日常问答,综合能力均衡' }, { id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', provider: '硅基流动', desc: '高速推理,代码生成能力强,价格极低' },
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', provider: '', desc: '高速推理,代码生成强' }, { id: 'sensenova-6.7-flash-lite', label: 'SenseNova 6.7 Flash Lite', provider: '商汤科技', desc: '轻量快速,日常对话与文本处理' },
{ id: 'sensenova-6.7-flash-lite', label: 'SenseNova 6.7 Flash Lite', provider: '', desc: '轻量快速,日常使用' },
]; ];
export const DEFAULT_MODEL = 'general'; export const DEFAULT_MODEL = 'deepseek-v4-flash';
+59
View File
@@ -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,
})),
};
}
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
ai-learning-platform (yuzhiran.com) 浏览器自动化测试
测试 SEO 优化、页面加载、功能完整性
"""
import subprocess
import json
import re
import sys
import time
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
from html.parser import HTMLParser
BASE_URL = "https://yuzhiran.com"
results = []
def run_test(name, test_func):
"""运行单个测试"""
try:
test_func()
results.append({"name": name, "status": "PASS", "error": None})
print(f"{name}")
except AssertionError as e:
results.append({"name": name, "status": "FAIL", "error": str(e)})
print(f"{name}: {e}")
except Exception as e:
results.append({"name": name, "status": "ERROR", "error": str(e)})
print(f" ⚠️ {name}: {e}")
def fetch_page(path, timeout=15):
"""获取页面内容"""
url = f"{BASE_URL}{path}"
req = Request(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; Hermes-Test/1.0)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
})
resp = urlopen(req, timeout=timeout)
return resp.read().decode('utf-8', errors='replace'), resp
def check_meta_tags(html, path="/"):
"""检查 meta 标签"""
# Title
title_match = re.search(r'<title[^>]*>(.*?)</title>', html, re.DOTALL | re.IGNORECASE)
assert title_match, f"Missing <title> tag on {path}"
title = title_match.group(1).strip()
assert len(title) > 5, f"Title too short: '{title}' on {path}"
# Description
desc_match = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert desc_match, f"Missing description meta tag on {path}"
desc = desc_match.group(1).strip()
assert len(desc) > 20, f"Description too short: '{desc[:50]}' on {path}"
# Keywords
kw_match = re.search(r'<meta[^>]+name=["\']keywords["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert kw_match, f"Missing keywords meta tag on {path}"
# Viewport
vp_match = re.search(r'<meta[^>]+name=["\']viewport["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert vp_match, f"Missing viewport meta tag on {path}"
vp = vp_match.group(1).strip()
assert "width=device-width" in vp.lower(), f"Viewport missing width=device-width on {path}"
# Canonical
canon_match = re.search(r'<link[^>]+rel=["\']canonical["\'][^>]+href=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert canon_match, f"Missing canonical link tag on {path}"
# Open Graph
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert og_match, f"Missing og:title meta tag on {path}"
# Theme color
tc_match = re.search(r'<meta[^>]+name=["\']theme-color["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert tc_match, f"Missing theme-color meta tag on {path}"
def check_structured_data(html, path="/"):
"""检查结构化数据 (JSON-LD)"""
ld_match = re.search(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.DOTALL | re.IGNORECASE)
assert ld_match, f"Missing JSON-LD structured data on {path}"
ld_content = ld_match.group(1).strip()
try:
data = json.loads(ld_content)
assert '@context' in data, f"JSON-LD missing @context on {path}"
assert '@type' in data, f"JSON-LD missing @type on {path}"
except json.JSONDecodeError:
assert False, f"Invalid JSON-LD on {path}"
def check_performance(html, path="/"):
"""检查性能相关优化"""
# Check for inline scripts (should be minimal)
inline_scripts = re.findall(r'<script[^>]*>(?!.*?</script>)([^<]{100,})', html, re.DOTALL | re.IGNORECASE)
# Large inline scripts are bad for performance
large_scripts = [s for s in inline_scripts if len(s) > 5000]
assert len(large_scripts) <= 2, f"Too many large inline scripts ({len(large_scripts)}) on {path}"
# Check for preconnect hints
preconnect = re.findall(r'<link[^>]+rel=["\']preconnect["\']', html, re.IGNORECASE)
# Not required but good to have
print(f" Preconnect hints: {len(preconnect)}")
def check_semantic_html(html, path="/"):
"""检查语义化 HTML"""
# Should have proper heading structure
h1_count = len(re.findall(r'<h1[^>]*>', html, re.IGNORECASE))
assert h1_count >= 1, f"No <h1> tag on {path}"
# Should have main element or equivalent
main_match = re.search(r'<main[^>]*>', html, re.IGNORECASE)
nav_match = re.search(r'<nav[^>]*>', html, re.IGNORECASE)
assert main_match or nav_match, f"No <main> or <nav> element on {path}"
def check_image_optimization(html, path="/"):
"""检查图片优化"""
# Check for alt attributes on images
images = re.findall(r'<img[^>]*>', html, re.IGNORECASE)
for img in images[:10]: # Check first 10 images
alt_match = re.search(r'alt=["\'](.*?)["\']', img, re.IGNORECASE)
if not alt_match:
assert False, f"Image missing alt attribute on {path}"
def check_link_validity(html, path="/"):
"""检查链接有效性"""
links = re.findall(r'<a[^>]+href=["\'](.*?)["\'][^>]*>', html, re.IGNORECASE)
# Check for broken internal links
broken = []
for link in links[:20]: # Check first 20 links
if link.startswith('#') or link.startswith('mailto:') or link.startswith('tel:'):
continue
if link.startswith('http'):
continue
try:
req = Request(f"{BASE_URL}{link}", headers={"User-Agent": "Hermes-Test/1.0"})
resp = urlopen(req, timeout=5)
if resp.status >= 400:
broken.append(link)
except:
pass
assert len(broken) == 0, f"Broken links found: {broken}"
# ==================== TESTS ====================
print("=" * 60)
print("🧪 ai-learning-platform (yuzhiran.com) 浏览器自动化测试")
print("=" * 60)
# 1. 首页 SEO
print("\n📄 首页 SEO 测试")
run_test("首页 SEO 优化", lambda: check_meta_tags(*fetch_page("/")))
run_test("首页结构化数据", lambda: check_structured_data(*fetch_page("/")))
run_test("首页性能检查", lambda: check_performance(*fetch_page("/")))
run_test("首页语义化 HTML", lambda: check_semantic_html(*fetch_page("/")))
run_test("首页图片优化", lambda: check_image_optimization(*fetch_page("/")))
run_test("首页链接有效性", lambda: check_link_validity(*fetch_page("/")))
# 2. 重要页面 SEO
print("\n📄 重要页面 SEO 测试")
important_pages = ["/tools", "/courses", "/skills", "/prompts", "/practices", "/sandbox", "/about"]
for page in important_pages:
run_test(f"{page} SEO 优化", lambda p=page: check_meta_tags(*fetch_page(p)))
run_test(f"{page} 结构化数据", lambda p=page: check_structured_data(*fetch_page(p)))
# 3. robots.txt
print("\n🤖 robots.txt 测试")
def test_robots():
html, resp = fetch_page("/robots.txt")
assert "User-agent" in html, "robots.txt missing User-agent"
assert "Disallow" in html, "robots.txt missing Disallow rules"
assert "Sitemap" in html, "robots.txt missing Sitemap"
assert "/admin/" in html, "robots.txt should disallow /admin/"
assert "/my/" in html, "robots.txt should disallow /my/"
test_robots()
# 4. sitemap.xml
print("\n🗺️ sitemap.xml 测试")
def test_sitemap():
html, resp = fetch_page("/sitemap.xml")
assert "<urlset" in html, "Invalid sitemap format"
assert "<loc>" in html, "Sitemap missing <loc> entries"
url_count = len(re.findall(r'<loc>', html))
assert url_count > 10, f"Sitemap has only {url_count} URLs"
test_sitemap()
# 5. PWA manifest
print("\n📱 PWA manifest 测试")
def test_manifest():
html, resp = fetch_page("/site.webmanifest")
data = json.loads(html)
assert "name" in data, "Manifest missing name"
assert "start_url" in data, "Manifest missing start_url"
assert "icons" in data, "Manifest missing icons"
assert "theme_color" in data, "Manifest missing theme_color"
assert "shortcuts" in data, "Manifest missing shortcuts"
test_manifest()
# 6. 页面加载性能
print("\n⚡ 页面加载性能测试")
def test_homepage_load():
start = time.time()
html, resp = fetch_page("/")
load_time = time.time() - start
assert load_time < 5, f"Homepage took {load_time:.2f}s to load"
print(f" Load time: {load_time:.2f}s")
test_homepage_load()
def test_tools_load():
start = time.time()
html, resp = fetch_page("/tools")
load_time = time.time() - start
assert load_time < 5, f"/tools took {load_time:.2f}s to load"
print(f" Load time: {load_time:.2f}s")
test_tools_load()
# 7. 移动端适配
print("\n📱 移动端适配测试")
def test_mobile_viewport():
html, resp = fetch_page("/")
vp_match = re.search(r'<meta[^>]+name=["\']viewport["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert vp_match, "Missing viewport meta tag"
vp = vp_match.group(1).strip()
assert "initial-scale=1" in vp.lower(), "Viewport missing initial-scale"
test_mobile_viewport()
# 8. 暗黑模式支持
print("\n🌙 暗黑模式测试")
def test_dark_mode():
html, resp = fetch_page("/")
# Check for dark mode CSS variables or class
dark_match = re.search(r'dark:', html)
assert dark_match, "No dark mode CSS found"
test_dark_mode()
# 9. 国际化支持
print("\n🌐 国际化测试")
def test_i18n():
html, resp = fetch_page("/")
# Check for lang attribute
lang_match = re.search(r'<html[^>]+lang=["\']zh-CN["\']', html, re.IGNORECASE)
assert lang_match, "Missing lang='zh-CN' attribute"
test_i18n()
# 10. 安全头检查
print("\n🔒 安全头检查")
def test_security_headers():
html, resp = fetch_page("/")
# Check if HTTPS
assert BASE_URL.startswith("https"), "Not using HTTPS"
# Check for CSP (optional but good)
# Check for HSTS (optional)
print(" HTTPS: ✅")
test_security_headers()
# ==================== SUMMARY ====================
print("\n" + "=" * 60)
print("📊 测试总结")
print("=" * 60)
passed = sum(1 for r in results if r["status"] == "PASS")
failed = sum(1 for r in results if r["status"] == "FAIL")
errors = sum(1 for r in results if r["status"] == "ERROR")
total = len(results)
print(f"\n总计: {total} 项测试")
print(f" ✅ 通过: {passed}")
print(f" ❌ 失败: {failed}")
print(f" ⚠️ 错误: {errors}")
print(f" 📈 通过率: {passed/total*100:.1f}%")
if failed > 0:
print("\n❌ 失败的测试:")
for r in results:
if r["status"] == "FAIL":
print(f" - {r['name']}: {r['error']}")
if errors > 0:
print("\n⚠️ 出错的测试:")
for r in results:
if r["status"] == "ERROR":
print(f" - {r['name']}: {r['error']}")
sys.exit(0 if failed == 0 and errors == 0 else 1)
+39 -2
View File
@@ -2,8 +2,45 @@
<html lang="zh-CN"> <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>