P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善

- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日
- 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块
- 导航: 主菜单隐藏企业版/社区(URL 可访问)
- 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写
- 工具页: affiliateLink 绿色推荐 Badge
- SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127
- 沙盒: 新增编程导师场景(苏格拉底教学法)
- 练习系统: Practices 多场景练习(含结构化评分)
- 技能广场: 6 个付费 Skill(标题大师/回款助手等)
- 管理后台: Models/Posts/Practices CRUD 页面
- 文档: README + progress.md 全面更新;AGENTS.md 同步定位
- 清理: .env.example 移除;tsbuildinfo gitignore
This commit is contained in:
yuzhiran-dev
2026-06-18 18:14:07 +08:00
parent fb8152401b
commit 0f215d2aad
103 changed files with 5240 additions and 778 deletions
+6
View File
@@ -53,6 +53,12 @@ backend/cert/key/*.pem
backend/cert/key/*.p12
backend/cert/key/*.txt
# Build cache
*.tsbuildinfo
# AI agent workspace
.omo/
# Temp files
*.tmp
*.temp
+28 -13
View File
@@ -4,7 +4,7 @@
## 项目概览
宇之然 AI 学习与实践平台。前端 Next.js + shadcn/ui,后端 NestJS + MySQL。
宇之然 AI 工具指南与技能练习平台。前端 Next.js + shadcn/ui,后端 NestJS + MySQL。
| 项目 | 值 |
|------|-----|
@@ -41,19 +41,28 @@
- hover → `hover:text-foreground` / `hover:bg-accent`
- **国际化**: 所有用户可见文本使用 `useT()` hook,翻译 key 位于 `src/i18n/locales/`。新增页面先加翻译 key 再写 UI。中文默认,英文同步维护。
## 关键架构决策
## 2026 市场验证数据(已修正)
| 决策 | 方案 | 原因 |
| 指标 | 数据 | 来源 |
|------|------|------|
| CORS | `origin: true` + `maxAge: 0` | 避免预检缓存, 开发灵活 |
| Favicon | `public/favicon.png` + `app/icon.svg` | `.ico``output: 'export'` 不兼容 |
| 静态导出 | `output: 'export'` | 部署到静态托管 |
| Sandbox 布局 | `h-[calc(100vh-4rem)] flex flex-col` | 固定头尾, 消息区滚动 |
| 流式聊天 | `ReadableStream` fetch API | 实时 AI 响应 |
| 法律页面 | `max-w-3xl` 窄容器 | 长文本可读性 |
| 模型列表 | `frontend/src/lib/models.ts` | 全站唯一数据源,3 个页面统一导入 |
| 颜色约定 | 全站禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` | 必须使用 CSS 变量 |
| 学情分析 | `GET /api/v1/learning/analytics` + `GET /api/v1/learning/path` | 基于对话知识度分析 |
| 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
@@ -72,7 +81,8 @@
| `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 问题) |
| `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` | 难度等级列表 |
@@ -82,7 +92,12 @@
- `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)
## 项目管理流程
+83 -74
View File
@@ -1,6 +1,6 @@
# 宇之然 AI (YuZhiRan AI)
# 宇之然 AI 工具指南与技能练习平台
AI 学习与实践平台 — 让每个人都能用好 AI
AI 工具指南 · 实战练习 · 技能提升 — 让每个人都能用好 AI
| 信息 | 内容 |
|------|------|
@@ -14,85 +14,94 @@ AI 学习与实践平台 — 让每个人都能用好 AI
```
ai-learning-platform/
├── README.md # 项目说明
├── AGENTS.md # AI 项目知识库(开发助手专用)
├── docs/ # 项目文档
│ ├── progress.md # 进度追踪
│ ├── archive/ # 历史进度存档
│ ├── 产品规划书.md # 完整产品规划(含商业模式、可行性分析)
│ ├── 技术架构设计.md # 技术选型与系统架构
│ └── 合规与运营方案.md # ICP合规、数据合规、运营策略
├── frontend/ # 官网前端(Next.js + Tailwind
├── mobile/ # 移动端 App/小程序
├── frontend/ # 前端(Next.js 14 App Router + shadcn/ui
├── backend/ # 后端 APINestJS + Prisma + MySQL
│ ├── src/modules/payment/ # 微信支付模块(已完善)
│ ├── src/modules/ai/ # AI模型接入(支持OpenAI兼容接口
└── prisma/ # 数据模型定义
│ ├── src/modules/ # 功能模块
│ ├── payment/ # 支付模块(统一支付网关对接
│ ├── ai/ # AI 模型接入(OpenAI/Doubao/DeepSeek
│ │ ├── sandbox/ # AI 实操沙箱(多场景练习)
│ │ ├── skills/ # 技能系统(付费 Skill 商店)
│ │ ├── tools/ # AI 工具指南
│ │ ├── practices/ # 练习系统
│ │ └── marketplace/ # 技能广场 API
│ ├── prisma/ # 数据模型定义(含 seed 脚本)
│ └── cert/ # 支付证书
└── scripts/ # 工具脚本
└── setup.sh # 项目初始化脚本
└── deploy.sh # 部署脚本
```
## 技术栈
### 后端
- **框架**: NestJS (Node.js)
- **ORM**: Prisma
- **数据库**: MySQL
- **支付**: 微信支付 V3 SDK
- **AI接口**: OpenAI兼容接口、DeepSeek、通义千问
### 前端
- **框架**: Next.js 14
- **样式**: Tailwind CSS
- **状态管理**: React Hooks
## 快速开始
### 1. 初始化项目
```bash
cd ai-learning-platform
./scripts/setup.sh
```
### 2. 手动配置
#### 后端配置
编辑 `backend/.env` 文件,配置:
- 微信支付参数(已从 `/www/wwwroot/sharefile/note.txt` 获取)
- AI模型API密钥
- 数据库密码
#### 前端配置
编辑 `frontend/.env.local` 文件,配置API地址。
### 3. 启动开发环境
```bash
# 启动后端(默认端口3000
cd backend && npm run dev
# 启动前端(默认端口3001
cd frontend && npm run dev
```
### 4. 访问
- 前端: http://localhost:3001
- 后端API文档: http://localhost:3000/api
## 核心功能
- ✅ 用户系统(注册、登录、会员管理)
- ✅ 微信支付集成(JSAPI/NATIVE/MWEB
- ✅ 会员订阅管理(月卡/年卡自动续期)
- ✅ AI模型接入(支持美团longcat等OpenAI兼容接口)
- ✅ 课程系统
- ✅ 提示词库
- ✅ AI实操沙箱
- ✅ 社区功能
## 产品定位
面向大众化分领域用户的 AI 学习与实践平台,涵盖
- AI 基础知识与通识教育
- 提示词工程教学与模板库
- 智能体(Agent)使用教程
- 大模型介绍与选型指南
- AI 实操沙箱体验
面向大众**AI 工具指南与技能练习平台**,帮助用户
- 发现优质 **AI 工具**(含返佣推荐)
- 系统学习 **Prompt 工程**(体系化课程)
- **AI 实操沙箱** 中练习提示词技能
- 购买一次性付费 **AI 技能**(垂直场景解决方案)
- 通过 **编程导师** 苏格拉底式学习编程
## 技术栈
### 前端
- **框架**: Next.js 14 (App Router, `output: 'export'` 静态导出)
- **UI**: shadcn/ui + Tailwind CSS(暗黑模式,CSS 变量主题)
- **国际化**: next-intl(中/英文,中文默认)
- **状态管理**: React Server Components 优先,客户端 `useState`/`useEffect`
### 后端
- **框架**: NestJS (Node.js)
- **ORM**: Prisma + MySQL
- **认证**: JWT + Passport
- **AI 接口**: OpenAI 兼容接口(DeepSeek、字节豆包、通义千问)
- **支付**: 统一支付网关(yzrcloud.cn,支持支付宝/微信)
## 快速开始
```bash
# 后端开发(端口 4000
cd backend
cp .env.example .env # 配置数据库密码、AI 密钥等
npm install
npx prisma generate
npx prisma db push # 初始化数据库
npm run start:dev
# 前端开发(端口 3000
cd frontend
cp .env.local.example .env.local # 或直接设置 NEXT_PUBLIC_API_URL=/api/v1
npm install
npm run dev
```
**注意**: 前端为 SSG`output: 'export'`),构建时需要后端运行在 `localhost:4000` 以确保 `generateStaticParams` 能获取动态路由参数。
## 核心功能
| 功能 | 说明 | 状态 |
|------|------|------|
| 🛠️ **AI 工具指南** | 收录优质 AI 工具,含返佣链接推荐 | ✅ 已上线 |
| 📚 **课程系统** | AI 系统课程(免费/付费),含章节和课时 | ✅ 已上线 |
| 🧪 **AI 实操沙箱** | 多场景提示词练习(写作/编程/客服/翻译/编程导师) | ✅ 已上线 |
| 💰 **技能广场** | 一次性付费购买垂直场景 AI 技能(¥9.9-¥29.9) | ✅ 已上线 |
| 👤 **用户系统** | 注册/登录/会员管理 | ✅ 已上线 |
| 🧑‍🏫 **编程导师** | 苏格拉底式教学,不直接给答案 | ✅ 已上线 |
| 📊 **学情分析** | 知识领域掌握度跟踪 | ✅ 已上线 |
| 🏢 **企业版** | 企业 AI 培训管理(隐藏菜单,URL 可访问) | ⏸️ 暂停运营 |
## 部署架构
```
用户 → Nginx (443/80) → /api/* → backend (127.0.0.1:4000, PM2)
→ /* → static files (/www/wwwroot/www.yuzhiran.com/)
```
- 前端: Next.js SSG 导出到 `/www/wwwroot/www.yuzhiran.com/`Nginx 直接提供
- 后端: NestJS + PM2 常驻 4000 端口
- 数据库: MySQL 8.0 (systemd)
- Web 服务器: Nginx(宝塔面板管理)
-32
View File
@@ -1,32 +0,0 @@
# Database
DATABASE_URL="mysql://yuzhiran:yuzhiran123@localhost:3306/yuzhiran"
# JWT
JWT_SECRET=yuzhiran-jwt-secret-2024
JWT_EXPIRES_IN=2h
# Server
PORT=4000
# AI Models (OpenAI-compatible)
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
AI_DEFAULT_MODEL=gpt-3.5-turbo
# DeepSeek
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
# DashScope (通义千问)
DASHSCOPE_API_KEY=
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# WeChat Pay
WX_APPID=
WX_MCHID=
WX_KEY=
WX_CERT_PATH=
WX_CERT_KEY_PATH=
# WeChat Mini Program
WX_SECRET=
+117
View File
@@ -17,13 +17,16 @@
"@nestjs/platform-express": "^11.1.19",
"@nestjs/serve-static": "^5.0.5",
"@nestjs/swagger": "^11.4.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"alipay-sdk": "^4.14.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^5.22.0",
@@ -37,6 +40,7 @@
"@nestjs/testing": "^11.1.19",
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
@@ -1837,6 +1841,12 @@
}
}
},
"node_modules/@ioredis/commands": {
"version": "1.10.0",
"resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.10.0.tgz",
"integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -3218,6 +3228,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -3562,6 +3583,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmmirror.com/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -5440,6 +5471,15 @@
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -5647,6 +5687,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmmirror.com/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -5806,6 +5865,15 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -7432,6 +7500,28 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.11.1",
"resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-5.11.1.tgz",
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.10.0",
"cluster-key-slot": "1.1.1",
"debug": "4.4.3",
"denque": "2.1.0",
"redis-errors": "1.2.0",
"redis-parser": "3.0.0",
"standard-as-callback": "2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -9854,6 +9944,27 @@
"node": ">= 6"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -10399,6 +10510,12 @@
"node": ">=10"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+4
View File
@@ -37,13 +37,16 @@
"@nestjs/platform-express": "^11.1.19",
"@nestjs/serve-static": "^5.0.5",
"@nestjs/swagger": "^11.4.2",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0",
"alipay-sdk": "^4.14.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^5.22.0",
@@ -57,6 +60,7 @@
"@nestjs/testing": "^11.1.19",
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
+75 -5
View File
@@ -44,7 +44,8 @@ model User {
status UserStatus @default(ACTIVE)
memberPlan MemberPlan @default(FREE)
memberExpire DateTime?
sandboxDaily Int @default(10)
sandboxDaily Int @default(5)
sandboxExtra Int @default(0)
followerCount Int @default(0)
followingCount Int @default(0)
postCount Int @default(0)
@@ -68,6 +69,8 @@ model User {
circleMemberships CircleMember[]
organizationMemberships OrganizationMember[]
notifications Notification[]
practiceSubmissions PracticeSubmission[]
userSkills UserSkill[]
@@map("users")
}
@@ -132,7 +135,7 @@ model Lesson {
id Int @id @default(autoincrement())
chapterId Int
title String
content String?
content String? @db.Text
videoUrl String?
duration Int?
sortOrder Int @default(0)
@@ -207,6 +210,7 @@ model Tool {
description String?
url String
icon String?
affiliateLink String?
categoryId Int?
tags String?
isFeatured Boolean @default(false)
@@ -225,7 +229,7 @@ model Content {
id Int @id @default(autoincrement())
title String
summary String?
content String?
content String? @db.Text
cover String?
categoryId Int?
tags String?
@@ -276,11 +280,14 @@ model Skill {
tasks String @db.Text
tags String
prerequisiteIds String?
price Float? @default(0)
sortOrder Int @default(0)
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchasedBy UserSkill[]
@@map("skills")
}
@@ -313,17 +320,34 @@ model Order {
payChannel String?
transactionId String?
gatewayOrderId String?
metadata String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
userSkills UserSkill[]
@@index([userId, status])
@@index([status, createdAt])
@@map("orders")
}
model UserSkill {
id Int @id @default(autoincrement())
userId Int
skillId String
orderId Int?
purchasedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
skill Skill @relation(fields: [skillId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([userId, skillId])
@@map("user_skills")
}
model Subscription {
id Int @id @default(autoincrement())
userId Int
@@ -384,7 +408,7 @@ model Post {
id Int @id @default(autoincrement())
userId Int
title String
content String
content String @db.Text
tags String?
circleId Int?
status String @default("PUBLISHED")
@@ -547,6 +571,52 @@ model Notification {
@@map("notifications")
}
model PracticeQuestion {
id Int @id @default(autoincrement())
skillId String?
scenario String @db.Text
title String
description String @db.Text
instructions String @db.Text
difficulty String @default("BEGINNER")
category String @default("general")
expectedCriteria String @db.Text
starterCode String? @db.Text
hint String? @db.Text
sortOrder Int @default(0)
status String @default("PUBLISHED")
attemptCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submissions PracticeSubmission[]
@@map("practice_questions")
}
model PracticeSubmission {
id Int @id @default(autoincrement())
userId Int
questionId Int
answer String @db.Text
score Int?
maxScore Int @default(100)
feedback String? @db.Text
criteriaScores String? @db.Text
duration Int @default(0)
status String @default("SUBMITTED")
submittedAt DateTime @default(now())
scoredAt DateTime?
question PracticeQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([questionId])
@@index([userId, status])
@@map("practice_submissions")
}
model Banner {
id Int @id @default(autoincrement())
title String
+395
View File
@@ -0,0 +1,395 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Enriching seed data...\n');
// ===== 1. AI Models (12 models with real 2026 data) =====
console.log('Seeding AI models...');
const models = [
{ name: 'GPT-5.4', provider: 'OpenAI', description: 'OpenAI 最新旗舰模型,拥有强大的推理能力和多模态理解能力。在 coding benchmark LiveCodeBench 上得分 87.0%,支持 272K 上下文窗口。适合通用对话、编程、分析等多种任务。', capabilities: '文本生成、代码编写、图像理解、语音对话', contextWindow: 272000, maxTokens: 32768, pricing: 'API: $1.25/1M input, $10/1M output; Plus $20/月', isFeatured: true, sortOrder: 1 },
{ name: 'GPT-5.4 Pro', provider: 'OpenAI', description: 'GPT-5.4 增强版,专门针对高精度推理场景优化。AIME 2025 数学竞赛得分 94%GPQA Diamond 得分约 88%。适合需要高准确率的复杂分析场景。', capabilities: '深度推理、数学竞赛、复杂分析、学术研究', contextWindow: 400000, maxTokens: 65536, pricing: 'Pro $200/月; API 按用量计费', isFeatured: true, sortOrder: 2 },
{ name: 'Claude Opus 4.6', provider: 'Anthropic', description: 'Anthropic 最强模型,在编程、长文档分析和深度推理方面表现领先。拥有 1M token 超长上下文窗口,编码能力领先行业。适合企业级复杂任务。', capabilities: '编码、长文档分析、深度推理、JSON 输出', contextWindow: 1000000, maxTokens: 65536, pricing: 'API: $5/1M input, $15/1M output; Pro $20/月; Max $200/月', isFeatured: true, sortOrder: 3 },
{ name: 'Claude Sonnet 4.6', provider: 'Anthropic', description: 'Claude 系列的均衡型号,在速度和质量的平衡上表现优异。支持 Agentic 工作流和 Computer Use 能力。适合生产环境部署。', capabilities: 'Agent 工作流、Computer Use、均衡推理', contextWindow: 500000, maxTokens: 32768, pricing: 'API: $3/1M input, $10/1M output', sortOrder: 4 },
{ name: 'Gemini 3.1 Pro', provider: 'Google', description: 'Google 最新旗舰模型,推理能力行业领先。在 GPQA 基准上得分 94.3%,输出速度达 114.8 tokens/s。支持长上下文和原生多模态。2026 年 3 月发布。', capabilities: '推理、多模态、长上下文、高速输出', contextWindow: 1000000, maxTokens: 65536, pricing: 'API: $2/1M input, $8/1M output; Advanced $19.99/月', isFeatured: true, sortOrder: 5 },
{ name: 'Gemini 2.5 Flash', provider: 'Google', description: 'Google 高性价比模型,价格仅 $0.10/1M tokens,但性能仍然强劲。适合高频调用场景,性价比业界最佳。', capabilities: '高速推理、低成本、批量处理', contextWindow: 200000, maxTokens: 16384, pricing: 'API: $0.10/1M input, $0.40/1M output', sortOrder: 6 },
{ name: 'DeepSeek V4', provider: 'DeepSeek', description: '深度求索最新开源自研模型,以极低API价格震撼市场。性能接近 GPT-5,但价格仅为其约 1/50。支持中文优化,开源可私有化部署。', capabilities: '开源、中文优化、低成本、私有化部署', contextWindow: 128000, maxTokens: 16384, pricing: 'API: $0.03/1M input, $0.10/1M output; 开源免费', isFeatured: true, sortOrder: 7 },
{ name: 'Llama 4', provider: 'Meta', description: 'Meta 开源大模型最新版本,支持本地部署和微调。社区生态活跃,拥有大量工具链和优化方案。适合数据敏感的企业场景。', capabilities: '开源、本地部署、可微调、社区生态', contextWindow: 128000, maxTokens: 16384, pricing: '开源免费,可本地部署', sortOrder: 8 },
{ name: 'Qwen 3.5 (通义千问)', provider: 'Alibaba Cloud', description: '阿里云最新版通义千问模型,中文理解和生成能力领先。深度整合阿里云生态,支持企业级部署。', capabilities: '中文优化、电商/办公场景、阿里云生态', contextWindow: 128000, maxTokens: 32768, pricing: 'API: ¥1/1M input; 企业版按量计费', sortOrder: 9 },
{ name: 'Grok 4.1', provider: 'xAI', description: 'xAI 开发的最新模型,以实时网络信息和幽默风格著称。在实时数据分析和社交媒体场景表现突出。', capabilities: '实时信息、社交媒体分析、代码生成', contextWindow: 256000, maxTokens: 16384, pricing: 'Premium+ $40/月', sortOrder: 10 },
{ name: 'GLM-5 (智谱清言)', provider: '智谱AI', description: '智谱AI最新自研模型,在中文理解和逻辑推理方面表现优异。支持多种任务类型,适合企业级应用。', capabilities: '中文理解、逻辑推理、代码生成', contextWindow: 128000, maxTokens: 16384, pricing: 'API: ¥2/1M input', sortOrder: 11 },
{ name: 'ERNIE 5.0 (文心一言)', provider: 'Baidu', description: '百度最新版文心大模型,深度整合百度搜索和知识图谱。在中文知识问答和内容生成方面有独特优势。', capabilities: '中文知识、搜索整合、多模态生成', contextWindow: 128000, maxTokens: 16384, pricing: 'API: ¥1.5/1M input; 专业版 $59.9/月', sortOrder: 12 },
];
for (const m of models) {
await prisma.aiModel.upsert({
where: { id: m.sortOrder },
update: { name: m.name, provider: m.provider },
create: m,
});
}
console.log(`${models.length} AI models`);
// ===== 1.5 Ensure categories exist =====
const defaultCatIds: Record<string, number> = {};
const neededCategories = [
{ name: 'AI 入门', slug: 'ai-basics', description: 'AI 基础知识与入门指南' },
{ name: '办公效率', slug: 'office-productivity', description: '用 AI 提升办公效率' },
{ name: '创意设计', slug: 'creative-design', description: 'AI 辅助创意与设计' },
{ name: '编程开发', slug: 'programming', description: 'AI 辅助编程开发' },
{ name: '教育学习', slug: 'education', description: 'AI 在教育中的应用' },
{ name: '提示词技巧', slug: 'prompt-engineering', description: '提示词工程与技巧' },
{ name: 'AI 工具', slug: 'ai-tools', description: 'AI 工具收录与评测' },
{ name: '模型百科', slug: 'model-encyclopedia', description: '大模型介绍与对比' },
];
for (const cat of neededCategories) {
const c = await prisma.category.upsert({
where: { slug: cat.slug },
update: {},
create: { name: cat.name, slug: cat.slug, description: cat.description },
});
defaultCatIds[cat.slug] = c.id;
}
// ===== 2. Content / Articles (12 articles) =====
console.log('Seeding articles...');
const articles = [
{
title: '2026 AI 教育市场报告:88% 学生已在使用 AI,但 80% 大学仍无规范',
summary: '根据 Digital Education Council 与 Stanford HAI 2026 报告,AI 在教育领域的使用率已达 88%,但 AI 素养培训严重滞后。本文深度解读最新数据及对教育者的启示。',
content: `## 核心发现\n\n2026 年是 AI 在教育领域从"实验"走向"标配"的转折点。根据多项权威调查:\n\n- **88% 的学生**已在学习中使用生成式 AI 工具(Digital Education Council 2026\n- **60% 的教师**每周使用 AI 辅助备课和批改作业(Gallup-Walton 2026\n- 使用 AI 的教师**平均每周节省 5.9 小时**,相当于每学年省出 6 周时间\n- 全球 AI 教育市场规模在 2025 年达到 **$75.7 亿美元**,同比增长 38.4%\n- 预计到 2034 年将达到 **$1123 亿美元**\n\n## 最大的问题\n\n尽管使用率极高,但仅 **7% 的学校**制定了正式的 AI 使用指导方针(UNESCO 2026 调查)。这导致:\n- 学生不知道哪些 AI 使用方式属于学术不端\n- 教师缺乏评估 AI 辅助作业的标准\n- 学校面临 AI 作弊检测的军备竞赛\n\n## 对教育者的建议\n\n1. **制定 AI 使用政策**:明确哪些场景允许/不允许使用 AI\n2. **开设 AI 素养课**:72% 的学生希望大学提供 AI 培训\n3. **重新设计评估方式**:从"考核记忆"转向"考核 AI 协作能力"\n4. **教师先行**:先培训教师,再指导学生`,
contentType: 'news',
categoryId: defaultCatIds['education'],
tags: 'AI教育,学生,大学,报告,数据',
authorName: '宇之然研究院',
isAiGenerated: false,
publishedAt: new Date('2026-05-15'),
viewCount: 2847,
},
{
title: 'Prompt 工程入门:从零开始写出高质量提示词',
summary: '提示词工程(Prompt Engineering)是 2026 年最热门的 AI 技能之一。本文从 COSTAR 框架开始,带你系统掌握写提示词的方法论。',
content: `## 为什么 Prompt 工程如此重要\n\n同样的 AI 模型,不同的提示词效果天差地别。2026 年 Prompt 工程市场规模已达 $6.73 亿,预计 2034 年将增长至 $67 亿。\n\n## COSTAR 框架\n\n这是 2026 年最主流的提示词框架:\n\n- **C**ontext(上下文):告诉 AI 它的角色和背景\n- **O**bjective(目标):明确你要它完成什么任务\n- **S**tyle(风格):指定输出风格(专业/通俗/幽默)\n- **T**one(语气):设定语气(正式/友好/紧迫)\n- **A**udience(受众):说明内容面向谁\n- **R**esponse(输出格式):指定格式(JSON/表格/段落)\n\n## 实战示例\n\n**不好的 Prompt**\n> 帮我写一封邮件\n\n**好的 Prompt**\n> 你是一位[客户成功经理],需要给[一位刚续费 1 年的企业客户]写一封感谢邮件。语气[专业且温暖],重点突出[他们选择我们之后的业务增长数据],最后[附上一个新功能的内测邀请]。格式:简短段落,不超过 200 字。\n\n## 进阶技巧\n\n1. **Chain-of-Thought**:让 AI 逐步思考再给答案\n2. **Few-Shot**:在提示词中给出 2-3 个示例\n3. **角色扮演**:给 AI 设定一个专业角色\n4. **约束条件**:明确什么不能做`,
contentType: 'tutorial',
categoryId: defaultCatIds['prompt-engineering'],
tags: 'Prompt工程,提示词,教程,入门',
authorName: '宇之然AI学院',
isAiGenerated: false,
publishedAt: new Date('2026-05-20'),
viewCount: 5632,
},
{
title: 'GPT-5.4 vs Claude 4.6 vs Gemini 3.12026 年大模型横评',
summary: '2026 年三大 AI 巨头全部更新了旗舰模型。本文从推理、编码、成本、速度四个维度进行全面的横向对比,帮你选择最适合的模型。',
content: `## 三强格局\n\n2026 年 3 月,OpenAI、Anthropic、Google 几乎同时发布了旗舰更新,市场上首次出现三款实力接近的顶级模型。\n\n## 推理能力对比\n\n| 维度 | GPT-5.4 Pro | Claude Opus 4.6 | Gemini 3.1 Pro |\n|------|------------|----------------|----------------|\n| AIME 2025 (数学) | **94%** | 92% | 91% |\n| GPQA Diamond (科学) | ~88% | ~86% | **94.3%** |\n| LiveCodeBench (编码) | 87.0% | **89.5%** | 85.2% |\n| MMLU (通用知识) | 91.5% | 90.8% | **93.1%** |\n\n## 速度对比\n\n| 模型 | 输出速度 |\n|------|---------|\n| GPT-5.4 | 82.1 tokens/s |\n| Claude Opus 4.6 | 72.3 tokens/s |\n| Gemini 3.1 Pro | **114.8 tokens/s** |\n\n## 价格对比\n\n| 模型 | 输入 (per 1M tokens) | 输出 (per 1M tokens) |\n|------|-------------------|--------------------|\n| GPT-5.4 | $1.25 | $10 |\n| Claude Opus 4.6 | $5 | $15 |\n| Gemini 3.1 Pro | $2 | $8 |\n| DeepSeek V4 | **$0.03** | **$0.10** |\n\n## 结论\n\n- **编程首选**Claude Opus 4.6\n- **科学推理首选**Gemini 3.1 Pro\n- **综合通用首选**GPT-5.4\n- **性价比首选**DeepSeek V4(价格仅为 GPT 的 1/50`,
contentType: 'article',
categoryId: defaultCatIds['ai-basics'],
tags: 'GPT,Claude,Gemini,模型对比,评测',
authorName: '宇之然研究院',
isAiGenerated: true,
publishedAt: new Date('2026-06-01'),
viewCount: 12458,
},
{
title: '企业 Shadow AI 治理:75% 员工在用未授权 AI,你该怎么办?',
summary: 'Microsoft 2026 报告显示 75% 的员工使用未授权的 AI 工具处理工作,48% 将公司数据粘贴到公共 AI 平台。本文提供落地治理方案。',
content: `## Shadow AI 有多严重?\n\n2026 年的数据令人震惊:\n\n- **75% 的员工**在工作中使用未授权的 AI 工具(Microsoft 2026\n- **48% 的员工**将公司数据粘贴到公共 AI 平台(Cisco 2026\n- **43% 的企业**已因员工使用外部 AI 发生过数据泄漏(Bitglass 2026\n- 平均每家企业有 **12+ 个未经批准的 AI 工具**被员工使用\n\n## 三步治理方案\n\n### 第一步:发现\n- 部署 DLP(数据防泄漏)监控出站 API 流量\n- 在网络网关层识别已知 AI 平台域名\n- 浏览器扩展检测(如 Forcepoint 的 Shadow AI 检测)\n\n### 第二步:管控\n- 禁止向未经批准的 AI 平台上传敏感数据\n- 建立"AI 白名单"制度\n- 为敏感部门部署私有化 AI\n\n### 第三步:赋能\n- 提供安全的企业级 AI 工具(数据不出域)\n- 制定 AI 使用指南,明确什么数据可以/不可以输入 AI\n- 定期 AI 安全培训`,
contentType: 'article',
tags: 'Shadow AI,企业安全,数据治理,合规',
authorName: '宇之然安全团队',
isAiGenerated: false,
publishedAt: new Date('2026-05-28'),
viewCount: 3891,
},
{
title: '2026 年最值得关注的 10 个 AI 工具',
summary: '从 ChatGPT(8.45亿月活)到 Cursor(增长最快的 AI IDE),本文盘点 2026 年上半年最值得关注的 10 个 AI 工具。',
content: `## 1. ChatGPT (OpenAI)\n- 月活用户:**8.45 亿**\n- 日处理提示词:**25 亿**\n- 2026 年新增:GPT-5.4 集成、Computer Use API\n\n## 2. Claude (Anthropic)\n- 月访问量:600 万\n- 核心优势:1M 上下文、编码能力领先\n- 2026 年新增:Claude Code CLI、Computer Use\n\n## 3. Gemini (Google)\n- 月活用户:6080 万\n- 核心优势:原生多模态、Google 生态整合\n- 2026 年新增:Gemini 3.1 Pro、Veo 视频生成\n\n## 4. GitHub Copilot\n- 行业地位:最广泛使用的 AI 编程助手\n- 已支持 VS Code、JetBrains、Neovim\n\n## 5. Cursor\n- 增长最快的 AI 代码编辑器\n- 熟悉 VS Code 的用户可零成本迁移\n\n## 6. Perplexity AI\n- 月访问量:1060 万\n- 定位:AI 搜索引擎\n\n## 7. Midjourney\n- 月活用户:6080 万\n- AI 图像生成领域的标杆\n\n## 8. Canva\n- 月活用户:2.86 亿\n- AI 设计工具的普及者\n\n## 9. Notion AI\n- AI 增强的知识管理与写作\n\n## 10. DeepSeek\n- 开源模型的价格屠夫\n- API 价格仅为 GPT 的 1/50`,
contentType: 'article',
categoryId: defaultCatIds['ai-basics'],
tags: 'AI工具,2026,推荐,盘点',
authorName: '宇之然编辑部',
isAiGenerated: false,
publishedAt: new Date('2026-06-05'),
viewCount: 7231,
},
{
title: 'AI 编程助手实战:用 Cursor 和 Copilot 提升 3 倍效率',
summary: '2026 年 AI 编程已从"玩具"变成"刚需"。Devpost 报告显示 1/4 的开发者提交已是 Agentic 项目。本文带你实战配置 AI 编程工作流。',
content: `## AI 编程的 2026 年现状\n\n- **78% 的企业**已在至少一个业务线使用 AI(McKinsey 2026\n- Devpost 2026 报告显示:**1/4 的社区项目提交**已是 Agentic AI 项目\n- 参与 6 次以上黑客松的开发者,将 AI 项目部署到生产的概率是初学者的 **2.2 倍**\n\n## Cursor + Copilot 双配置\n\n### 1. Cursor 基础设置\n\nCursor 是目前最受欢迎的 AI 原生 IDE,支持 Composer(多文件编辑)和 Chat(对话式编程):\n\n- **Tab 补全**:AI 预测你的下一步操作\n- **Cmd+K**:选中代码后直接用自然语言修改\n- **Composer**Ctrl+I):多文件协同编辑\n\n### 2. GitHub Copilot 高级玩法\n\n- **Agent Mode**AI 自主执行多步骤开发任务\n- **代码审查**AI 自动审查 PR\n- **测试生成**:一句命令生成完整测试套件`,
contentType: 'tutorial',
categoryId: defaultCatIds['programming'],
tags: '编程,Cursor,Copilot,效率,AI编程',
authorName: '宇之然技术团队',
isAiGenerated: false,
publishedAt: new Date('2026-06-03'),
viewCount: 4562,
},
{
title: '70% 企业已在用 AI Agent,你的团队准备好了吗?',
summary: 'Gartner 预测 40% 的企业应用将在 2026 年嵌入 AI Agent。PwC 调查显示 79% 的企业已采用 AI Agent。这篇文章帮你理解 AI Agent 浪潮。',
content: `## AI Agent 的 2026 年\n\n- **40% 的企业应用**将在 2026 年包含 AI AgentGartner\n- **79% 的公司**已在组织内使用 AI AgentPwC 2026\n- **88% 的高管**计划因 Agentic AI 增加 AI 预算\n- **66% 的公司**表示 AI Agent 带来了可量化的生产力提升\n- **15% 的日常业务决策**将在 2026 年由 AI Agent 自主完成\n\n## AI Agent 能做什么?\n\n1. **客服 Agent**:自动处理 80% 的常见咨询\n2. **销售 Agent**:线索筛选、初步沟通、会议安排\n3. **运营 Agent**:自动监控、告警、修复\n4. **开发 Agent**:自主编码、测试、部署\n\n## 如何落地\n\n1. 从**低风险场景**开始(内部工具、自动化报表)\n2. 建立**人机协作**流程(Agent 执行 + 人工审批)\n3. 持续监控 Agent 的**决策质量**和**异常行为**`,
contentType: 'news',
tags: 'AI Agent,企业,趋势,Gartner',
authorName: '宇之然研究院',
isAiGenerated: true,
publishedAt: new Date('2026-05-25'),
viewCount: 3247,
},
{
title: 'AI 面试官来了:HR 如何应对 AI 招聘变革',
summary: '69% 的企业已在招聘中使用 AI,但 43% 的 AI 招聘系统存在偏见风险。本文从 HR 视角分析 AI 招聘的机遇与挑战。',
content: `## AI 招聘的现状\n\n- **69% 的公司**已在招聘流程中使用 AI(2026 年 SHRM 调查)\n- AI 简历筛选可**减少 75% 的初筛时间**\n- 但 **43% 的 AI 招聘系统**被发现存在种族或性别偏见\n\n## 主要应用场景\n\n1. **简历初筛**:自动匹配技能要求\n2. **面试评估**:AI 分析面试回答\n3. **候选人匹配**:智能推荐最适合的岗位\n\n## 避免偏见的黄金法则\n\n1. **审查训练数据**的性别/种族分布\n2. 使用**公平性指标**Demographic Parity < 0.1\n3. 保持**人工审核**环节\n4. 定期进行**偏见审计**`,
contentType: 'article',
tags: 'HR,招聘,AI面试,偏见审计',
authorName: '宇之然HR科技团队',
isAiGenerated: false,
publishedAt: new Date('2026-05-18'),
viewCount: 2158,
},
{
title: '从 ChatGPT 到 DeepSeek2026 年 AI 模型格局全览',
summary: '2026 年 AI 模型市场从三足鼎立变为群雄逐鹿。22 个前沿模型、5 大阵营、开源 vs 闭源格局全面分析。',
content: `## 2026 年格局变化\n\n2023 年只有 ChatGPT 一个玩家,2024 年出现三足鼎立(GPT/Claude/Gemini),2026 年已经扩展到 **22 个前沿模型**、**5 大模型阵营**:\n\n1. **OpenAI 阵营**GPT-5.4 / GPT-5 Mini / GPT-5 Nano\n2. **Anthropic 阵营**Claude Opus / Sonnet / Haiku\n3. **Google 阵营**Gemini Pro / Flash / Flash-Lite\n4. **中国阵营**DeepSeek V4 / Qwen 3.5 / GLM-5 / ERNIE 5.0\n5. **开源阵营**Llama 4 / Mistral / Grok\n\n## 关键趋势\n\n- **开源追赶闭源**DeepSeek V4 性能接近 GPT-5,价格仅 1/50\n- **推理模型崛起**:思维链(CoT)成为标配\n- **多模态普及**:从"文本对话"到"看图、听音、读文档"\n- **Agent 化**:从回答问题到执行任务`,
contentType: 'article',
categoryId: defaultCatIds['ai-basics'],
tags: 'AI模型,格局,2026,开源,对比',
authorName: '宇之然研究院',
isAiGenerated: false,
publishedAt: new Date('2026-05-30'),
viewCount: 8764,
},
{
title: '零基础用 AI 做数据分析:从 Excel 到 Copilot',
summary: '不会 Python 也能做数据分析?2026 年 AI 让数据分析民主化。本文教你用自然语言完成数据分析和可视化。',
content: `## 传统方式 vs AI 方式\n\n**传统数据分析**:学习 Python + Pandas + Matplotlib = 3 个月\n**AI 辅助分析**:自然语言描述需求 = 3 分钟\n\n## 三步上手\n\n### 1. 数据准备\n将 CSV 或 Excel 直接上传到 AI 工具:\n- **ChatGPT Code Interpreter**:适合单次分析\n- **Gemini**:处理 Google Sheets 数据\n- **Copilot for Excel**:直接在 Excel 中用自然语言操作\n\n### 2. 用自然语言提问\n\n> "分析这个月各区域的销售额变化趋势,标记出增长最快的三个品类,用折线图展示"\n\nAI 会自动生成代码、执行分析、返回图表。\n\n### 3. 迭代优化\n\n> "把图表按季度分组,只显示华东和华南区域的数据"\n\n不需要写代码,就像跟一个数据分析师对话。\n\n## 实用场景\n\n| 场景 | AI 指令示例 |\n|------|------------|\n| 销售分析 | "上月各产品线增长率" |\n| 用户分析 | "用户的活跃度分布" |\n| 库存分析 | "哪些 SKU 周转最慢" |\n| 财务分析 | "本季度费用结构占比" |`,
contentType: 'tutorial',
categoryId: defaultCatIds['office-productivity'],
tags: '数据分析,Excel,AI,办公效率,零基础',
authorName: '宇之然数据分析团队',
isAiGenerated: false,
publishedAt: new Date('2026-06-08'),
viewCount: 5431,
},
{
title: 'AI 伦理:当算法决定谁进面试,我们该如何守护公平?',
summary: '2026 年多国 AI 法案全面生效,AI 公平性从"道德选择"变成"法律义务"。本文深入探讨 AI 决策中的伦理问题。',
content: `## 算法偏见案例\n\n2025 年,一家科技公司引入 AI 简历筛选后,女性候选人进面率从 38% 骤降到 12%。罪魁祸首不是恶意,而是历史数据中学到的偏见。\n\n## 三大偏见来源\n\n1. **训练数据偏差**:历史 hire 数据中某性别占比 85%,模型误以为性别是重要特征\n2. **特征选择偏差**:如"职业空窗期"对女性不公(因产假等)\n3. **标注偏差**:面试官的主观偏好通过标注传递给了模型\n\n## 欧盟 AI 法案的启示\n\n2026 年全面生效的欧盟 AI 法案将招聘 AI 归类为"高风险系统",要求:\n- 建立风险管理系统\n- 使用代表性训练数据\n- 确保人类审查权\n- 达到准确性和公平性标准\n- 违反将面临最高全球营收 **6% 的罚款**\n\n## 公平性建议\n\n1. 使用 **Demographic Parity** 等量化指标评估偏见\n2. 采用 **Adversarial Debiasing** 等去偏算法\n3. 定期进行**公平性审计**\n4. 保持**人在回路**Human-in-the-loop`,
contentType: 'article',
tags: 'AI伦理,偏见,公平性,法律,合规',
authorName: '宇之然研究院',
isAiGenerated: false,
publishedAt: new Date('2026-05-22'),
viewCount: 1987,
},
{
title: '中小企业 AI 落地指南:从 0 到 1 搭建 AI 工作流',
summary: '预算有限、没有 AI 团队的中小企业如何落地 AI?本文提供一套从选型到部署的完整方案,含工具推荐和 ROI 估算。',
content: `## 中小企业的 AI 困境\n\n- 78% 的大企业已系统化使用 AIMcKinsey 2026\n- 但只有 **23% 的中小企业**有 AI 落地计划\n- 最大障碍:**预算不足**和**人才缺失**\n\n## 低成本方案\n\n### 第一步:选对工具(预算:¥0-500/月)\n\n- **ChatGPT Plus**$20/月):通用助手\n- **Claude Pro**$20/月):深度分析\n- **DeepSeek**(按量):低成本 API\n\n### 第二步:找准场景(投入 2 周)\n\n优先落地**高频率、低风险**的场景:\n1. 客服话术生成 → 省时 10 小时/周\n2. 产品文案撰写 → 省时 15 小时/周\n3. 数据分析报表 → 省时 8 小时/周\n\n### 第三步:建立规范(投入 1 周)\n\n- 制定 AI 使用指南\n- 明确哪些数据不能输入 AI\n- 建立 AI 输出审核机制\n\n## 投资回报\n\n| 投入 | 月成本 | 月节省工时 | 折合价值 |\n|------|--------|-----------|---------|\n| AI 工具订阅 | ¥500 | 120 小时 | ≈¥30,000 |\n| 员工培训 | 一次性 ¥5,000 | - | - |\n| **投入产出比** | **1:60** | | |`,
contentType: 'tutorial',
categoryId: defaultCatIds['office-productivity'],
tags: '中小企业,落地,指南,工作流,AI',
authorName: '宇之然企业服务团队',
isAiGenerated: false,
publishedAt: new Date('2026-06-10'),
viewCount: 3890,
},
];
// First delete existing contents to re-seed (for idempotency)
await prisma.content.deleteMany();
for (const a of articles) {
await prisma.content.create({ data: a });
}
console.log(`${articles.length} articles`);
// ===== 3. More Prompts (12 additional) =====
console.log('Seeding additional prompts...');
const prompts = [
{ title: 'SWOT 分析生成器', content: '请对[公司/项目名称]进行 SWOT 分析。\n\n背景:[提供背景信息]\n\n要求:\n1. 从内部视角分析 Strengths(优势)和 Weaknesses(劣势)\n2. 从外部视角分析 Opportunities(机会)和 Threats(威胁)\n3. 每部分至少 3 点\n4. 最后给出 2-3 条可执行的战略建议', description: '快速生成专业的 SWOT 商业分析', categoryId: defaultCatIds['office-productivity'], tags: '商业分析,战略,SWOT', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '代码评审助手', content: '请对以下代码进行 Code Review\n\n```\n[粘贴代码]\n```\n\n评审要求:\n1. 找出潜在的 Bug 和安全漏洞\n2. 检查代码风格和命名规范\n3. 建议性能优化方案\n4. 评估单元测试覆盖率\n5. 按严重程度分级(Critical / Major / Minor', description: 'AI 帮你做专业的代码评审', categoryId: defaultCatIds['programming'], tags: '编程,Code Review,代码质量', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '社交媒体文案撰写', content: '请为[品牌/产品]撰写一条社交媒体推广文案。\n\n品牌:[品牌名]\n目标受众:[描述目标人群]\n推广内容:[要推广的内容]\n平台:[抖音/小红书/微信/微博]\n\n要求:\n1. 一个吸引眼球的标题(制造好奇或紧迫感)\n2. 正文 100-150 字,有画面感、有情绪\n3. 包含 1 个明确的 Call-to-Action\n4. 添加 3-5 个相关话题标签\n5. 符合所选平台的调性', description: '快速生成社交媒体运营文案', categoryId: defaultCatIds['creative-design'], tags: '社交媒体,文案,营销', model: '通用', status: 'PUBLISHED' as const },
{ title: '学习计划制定助手', content: '请帮我制定一个[学习目标]的学习计划。\n\n学习目标:[要学什么]\n现有基础:[描述当前水平]\n可用时间:[每周几个小时]\n学习周期:[几周/几个月]\n\n要求:\n1. 按周分阶段的详细计划\n2. 每阶段包含具体的学习资源和练习方式\n3. 包含阶段性成果检验方式\n4. 学习建议和常见陷阱提醒', description: 'AI 定制个人学习计划', categoryId: defaultCatIds['prompt-engineering'], tags: '学习,计划,效率', model: '通用', status: 'PUBLISHED' as const },
{ title: '用户画像生成器', content: '请根据以下产品信息生成[产品名]的目标用户画像。\n\n产品:[产品名]\n核心功能:[功能列表]\n定价:[价格区间]\n竞品:[竞品列表]\n\n请生成至少 3 个不同的用户画像,每个画像包含:\n1. 基本信息(年龄、职业、收入)\n2. 痛点和需求\n3. 使用场景\n4. 决策因素\n5. 一句话描述这个用户', description: '用 AI 构建精准的用户画像', categoryId: defaultCatIds['office-productivity'], tags: '用户画像,产品,营销', model: '通用', status: 'PUBLISHED' as const },
{ title: '英文邮件润色助手', content: '请帮我润色以下英文邮件,使其更加专业、地道。\n\n原文:\n[粘贴英文邮件]\n\n上下文:\n收件人:[客户/同事/老板]\n目的:[告知/请求/感谢/投诉]\n期望语气:[正式/半正式/友好]\n\n润色要求:\n1. 保留原始信息的完整性\n2. 使用更地道、专业的表达\n3. 优化邮件结构和逻辑\n4. 标注修改了哪些部分及原因', description: 'AI 帮你写专业英文邮件', categoryId: defaultCatIds['office-productivity'], tags: '英文,邮件,商务', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '产品需求文档 (PRD) 生成', content: '请根据以下信息生成一份产品需求文档(PRD)。\n\n产品名称:[产品名]\n目标用户:[用户群体]\n核心问题:[要解决什么问题]\n核心功能:[功能列表]\n\n请按以下结构输出:\n1. 背景与目标\n2. 用户故事(至少 3 个)\n3. 功能需求(按优先级 P0/P1/P2)\n4. 非功能需求(性能、安全、可用性)\n5. 成功指标(OKR/KPI\n6. 开放问题', description: 'AI 辅助撰写产品需求文档', categoryId: defaultCatIds['office-productivity'], tags: '产品,PRD,需求文档', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: '面试模拟官', content: '请作为[岗位]面试官,对我进行模拟面试。\n\n岗位:[职位名称]\n我的经验:[X年经验,主要技能]\n面试类型:[技术/行为/综合]\n\n请逐个问题提问,我对每个问题回答后,你给出:\n1. 评分(1-5 分)\n2. 回答的亮点\n3. 可以改进的地方\n4. 参考答案要点\n\n总共问 5 个问题,从易到难。', description: 'AI 模拟面试官帮你准备面试', categoryId: defaultCatIds['prompt-engineering'], tags: '面试,求职,准备', model: '通用', status: 'PUBLISHED' as const },
{ title: '营销邮件自动化', content: '请为[活动/产品]撰写一封营销邮件。\n\n产品/活动:[名称]\n目标受众:[描述]\n核心卖点:[3 个核心价值]\nCTA:[希望用户做什么]\n\n邮件格式:\n1. 主题行(3 个选项,A/B 测试用)\n2. 预览文本(Preheader\n3. 正文(标题 + 痛点引入 + 解决方案 + 社会证明 + CTA\n4. P.S. 增加紧迫感', description: '生成高转化率的营销邮件', categoryId: defaultCatIds['creative-design'], tags: '营销,邮件,转化率', model: 'GPT-5', status: 'PUBLISHED' as const },
{ title: '竞品分析报告', content: '请对[行业/品类]进行竞品分析。\n\n需分析的竞品:[竞品 1,竞品 2,竞品 3]\n分析维度:\n1. 产品功能对比(用表格)\n2. 定价策略对比\n3. 目标用户对比\n4. 营销策略对比\n5. 优势和劣势总结\n6. 差异化机会建议', description: 'AI 生成专业的竞品分析', categoryId: defaultCatIds['office-productivity'], tags: '竞品分析,市场,产品', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'SQL 查询优化助手', content: '请帮我优化以下 SQL 查询:\n\n```sql\n[你的 SQL 语句]\n```\n\n数据库类型:[MySQL/PostgreSQL]\n数据量:[大约多少行]\n当前执行时间:[多少秒]\n期望目标:[如:1 秒以内]\n\n请给出:\n1. 当前 SQL 的性能问题分析\n2. 优化后的 SQL\n3. 建议添加的索引\n4. 预期性能提升', description: 'AI 帮你优化慢 SQL 查询', categoryId: defaultCatIds['programming'], tags: 'SQL,数据库,优化', model: 'Claude 4', status: 'PUBLISHED' as const },
{ title: 'API 文档生成器', content: '请根据以下代码/描述生成 API 文档:\n\n[粘贴 API 代码或描述]\n\n请按以下格式输出:\n1. 接口概述(用途、端点)\n2. 请求方法 + URL\n3. 请求头\n4. 请求参数(表格:参数名、类型、必填、说明)\n5. 请求示例(JSON)\n6. 成功响应示例\n7. 错误响应示例\n8. 调用限制说明', description: '自动生成结构化的 API 文档', categoryId: defaultCatIds['programming'], tags: 'API,文档,开发', model: '通用', status: 'PUBLISHED' as const },
];
await prisma.prompt.deleteMany();
for (const p of prompts) {
await prisma.prompt.create({ data: p });
}
console.log(`${prompts.length} prompts`);
// ===== 4. More Courses (3 additional courses) =====
console.log('Seeding additional courses...');
await prisma.course.deleteMany();
const courses = [
{
title: 'Prompt 工程:从入门到精通',
description: '系统学习提示词工程的核心框架、高级技巧和实战方法。课程包含 20+ 实战案例,覆盖写作、编程、分析等场景。',
categoryId: defaultCatIds['prompt-engineering'],
isFree: true,
status: 'PUBLISHED' as const,
sortOrder: 2,
chapters: {
create: [
{
title: '第一章:Prompt 工程基础',
sortOrder: 1,
lessons: {
create: [
{ title: '什么是 Prompt 工程', content: '# 什么是 Prompt 工程\n\nPrompt Engineering(提示词工程)是指设计和优化输入给 AI 模型的提示词,以获得期望输出的技术和方法论。\n\n## 为什么重要?\n\n- 同样的模型,不同的 Prompt 效果天差地别\n- 好的 Prompt = 精准的 AI 输出\n- 2026 年 Prompt 工程市场规模已达 $6.73 亿\n\n## 核心原则\n\n1. **清晰明确**:不要说"写点东西",要说"写一篇 300 字的电商产品介绍"\n2. **角色设定**:给 AI 一个角色("你是一名资深律师")\n3. **格式约束**:指定输出格式(JSON/表格/子弹列表)\n4. **示例引导**:给出 1-2 个例子(Few-Shot', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'COSTAR 框架详解', content: '# COSTAR 框架\n\n这是 2026 年最主流的提示词框架:\n\n- **C**ontext(上下文):设定背景和角色\n- **O**bjective(目标):明确任务目标\n- **S**tyle(风格):指定输出风格\n- **T**one(语气):设定语气\n- **A**udience(受众):说明面向对象\n- **R**esponse(响应格式):指定格式\n\n## 实战案例\n\n**不好的 Prompt**\n> 帮我写一份产品介绍\n\n**COSTAR 优化后:**\n> Context:你是一家 AI 公司的产品经理\n> Objective:介绍一款 AI 写作助手给潜在企业客户\n> Style:专业且有说服力\n> Tone:热情但不浮夸\n> Audience:企业 CTO 和内容负责人\n> Response:分 5 个要点,每个不超过 50 字', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
{
title: '第二章:高级技巧',
sortOrder: 2,
lessons: {
create: [
{ title: 'Chain-of-Thought 提示', content: '# Chain-of-Thought(思维链)\n\n让 AI 逐步推理再给出答案,显著提升复杂问题的准确率。\n\n## 示例\n\n**直接提问:**\n> Q:一个篮球和一副球拍总共 110 元,篮球比球拍贵 100 元,球拍多少钱?\n> A:10 元 ❌(常见错误答案)\n\n**CoT 方式:**\n> Q:一个篮球和一副球拍总共 110 元,篮球比球拍贵 100 元,球拍多少钱?\n> 让我们一步步思考:\n> 假设球拍价格为 x 元\n> 那么篮球价格为 x + 100 元\n> 总共:x + (x + 100) = 110\n> 2x = 10\n> x = 5\n> A:球拍 5 元 ✅', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'Few-Shot 与角色扮演', content: '# Few-Shot Prompting\n\n在提示词中给出 2-3 个示例,让 AI 理解输出模式。\n\n## 示例\n\n> 将以下句子转换为正式通知语气:\n> 示例1:\n> 原文:明天放假\n> 转换后:经公司研究决定,明日(2026年3月15日)全体休假一天。\n>\n> 示例2\n> 原文:会议室有人吗\n> 转换后:请各同事注意,第三会议室将于今日下午2时进行设备检修,届时请勿占用。\n>\n> 原文:{你的句子}\n> 转换后:', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
],
},
},
{
title: 'AI 办公效率提升实战',
description: '用 AI 提升日常办公效率:文档撰写、数据分析、会议管理、邮件处理。学完即可在工作中使用。',
categoryId: defaultCatIds['office-productivity'],
isFree: true,
status: 'PUBLISHED' as const,
sortOrder: 3,
chapters: {
create: [
{
title: '第一章:文档与写作',
sortOrder: 1,
lessons: {
create: [
{ title: 'AI 辅助撰写周报/日报', content: '# 用 AI 写周报\n\n## 常见痛点\n- 每周花 1 小时写周报\n- 不记得这周做了什么\n- 写出来没有亮点\n\n## AI 方法\n\n**Prompt 模板:**\n> 请帮我写一份本周工作总结。\n> 我的工作内容:[关键词]\n> 本周成果:[数据/项目]\n> 要求:按优先级排列,突出数据成果\n\n## 进阶技巧\n- 保持日常记录习惯(每天 2-3 条关键事项)\n- 用 AI 将零散记录整理为结构化周报\n- 让 AI 帮忙提炼"亮点"和"价值"', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'AI 会议纪要生成', content: '# AI 会议纪要\n\n## 传统方式\n- 手动录音 → 回听 → 整理 → 1 小时会议需要 2 小时整理\n\n## AI 方式\n- 实时转写 → AI 提取要点 → 自动分发给参会人 → 3 分钟完成\n\n## 必备工具\n1. **会议记录工具**:通义听悟、飞书妙记\n2. **AI 整理 Prompt**\n > 请为以下会议内容生成纪要:\n > [粘贴转写文字]\n > 格式:时间、参会人、议题、讨论要点、待办事项及负责人', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
{
title: '第二章:数据处理',
sortOrder: 2,
lessons: {
create: [
{ title: 'AI 辅助 Excel 数据分析', content: '# AI + Excel = 10 倍效率\n\n## 场景:销售数据分析\n\n不用学 VLOOKUP 和数据透视表,直接用自然语言让 AI 帮你:\n\n> "帮我分析这份销售数据:\n> 1. 按区域汇总 2025 年 Q4 销售额\n> 2. 同比增长率\n> 3. 找出增长最快和最慢的品类\n> 4. 用条件格式标出超过平均值的单元格"\n\n## 推荐工具\n- Microsoft Copilot for Excel\n- ChatGPT Code Interpreter\n- Google Gemini with Sheets', sortOrder: 1, status: 'PUBLISHED' as const },
],
},
},
],
},
},
{
title: 'AI 编程助手实战',
description: '2026 年开发者必备的 AI 编程技能。学习使用 Cursor、GitHub Copilot 等工具,将编码效率提升 2-3 倍。',
categoryId: defaultCatIds['programming'],
isFree: false,
price: 49.9,
status: 'PUBLISHED' as const,
sortOrder: 4,
chapters: {
create: [
{
title: '第一章:AI 编程工具概览',
sortOrder: 1,
lessons: {
create: [
{ title: 'CursorAI 原生 IDE', content: '# Cursor 使用指南\n\n## 核心功能\n\n1. **Tab 补全**AI 预测你的下一步操作\n2. **Cmd+K 内联编辑**:选中代码后用自然语言修改\n3. **Chat 面板**:对话式编程助手\n4. **Composer**:多文件协同编辑\n\n## 快速上手\n\n```bash\n# 安装\nbrew install --cask cursor\n# 或从 cursor.com 下载\n```\n\n## 必备快捷键\n| 快捷键 | 功能 |\n|--------|------|\n| Cmd+I | 打开 Composer |\n| Cmd+L | 打开 Chat |\n| Cmd+K | 内联编辑 |\n| Tab | 接受补全 |', sortOrder: 1, status: 'PUBLISHED' as const },
{ title: 'GitHub Copilot 进阶玩法', content: '# GitHub Copilot 高级技巧\n\n## Agent Mode\n\nCopilot 的 Agent Mode 可以自主执行多步骤任务:\n\n> "创建一个 RESTful API 端点,包含输入验证、错误处理和单元测试"\n\nCopilot 会:\n1. 创建路由文件\n2. 编写控制器逻辑\n3. 添加输入校验\n4. 编写测试用例\n5. 更新 API 文档\n\n## 代码审查\n\n在 PR 中使用 Copilot 自动审查:\n- 检查安全漏洞\n- 识别性能问题\n- 建议代码改进', sortOrder: 2, status: 'PUBLISHED' as const },
],
},
},
],
},
},
];
for (const courseData of courses) {
await prisma.course.create({ data: courseData });
}
console.log(`${courses.length} courses`);
// ===== 5. Banners (3 banners) =====
console.log('Seeding banners...');
const banners = [
{ title: 'AI 练习场 - 免费实战', image: '/images/banner-practice.jpg', link: '/practices', position: 'home', sortOrder: 1, status: 'PUBLISHED' },
{ title: '企业 AI 培训方案', image: '/images/banner-enterprise.jpg', link: '/enterprise', position: 'home', sortOrder: 2, status: 'PUBLISHED' },
{ title: 'AI 模型百科', image: '/images/banner-models.jpg', link: '/models', position: 'home', sortOrder: 3, status: 'PUBLISHED' },
];
await prisma.banner.deleteMany();
for (const b of banners) {
await prisma.banner.create({ data: b });
}
console.log(`${banners.length} banners`);
// ===== 6. More Tools (6 more) =====
console.log('Seeding additional tools...');
await prisma.tool.deleteMany();
const tools = [
{ 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: 'GitHub Copilot', description: '微软与 GitHub 推出的 AI 编程助手,支持 VS Code、JetBrains 等主流 IDE。可自动补全代码、生成函数、编写测试。', url: 'https://github.com/features/copilot', categoryId: defaultCatIds['ai-tools'], tags: '编程,代码,开发', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Cursor', description: '2026 年增长最快的 AI 原生代码编辑器,深度集成 AI 对话、代码生成和智能补全。VS Code 用户零迁移成本。', url: 'https://cursor.sh', categoryId: defaultCatIds['ai-tools'], tags: '编程,IDE,AI原生', isFeatured: true, status: 'PUBLISHED' as const },
{ name: 'Perplexity AI', description: 'AI 搜索引擎,结合实时网络搜索和大语言模型能力。提供可溯源的回答,附带来源引用。', url: 'https://perplexity.ai', categoryId: defaultCatIds['ai-tools'], tags: '搜索,研究,实时信息', isFeatured: true, 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) {
await prisma.tool.create({ data: t });
}
console.log(`${tools.length} tools`);
// ===== 7. Community Posts (5 demo posts) =====
console.log('Seeding community posts...');
await prisma.post.deleteMany();
await prisma.circle.deleteMany();
const demoUser = await prisma.user.findFirst();
if (demoUser) {
// Create a circle first
const circle = await prisma.circle.create({
data: {
name: 'AI 学习交流圈',
description: 'AI 学习者的交流社区,分享学习心得、工具推荐和行业见解。',
tags: 'AI,学习,交流',
creatorId: demoUser.id,
isPublic: true,
},
});
const posts = [
{ title: '分享:我用 GPT-5.4 和 Claude 4.6 做同一个数据分析任务的对比', content: '最近公司要做用户留存分析,我用 GPT-5.4 和 Claude 4.6 分别试了一下,结果很有意思:\n\n**GPT-5.4**:速度快,82 tokens/s,直接给出了 Python 代码和可执行的方案\n**Claude 4.6**:分析更深入,给了我 5 个不同的分析角度,很多我自己都没想到\n\n结论:GPT 适合快速出活,Claude 适合深度分析。建议两个都用,互补效果最好。\n\n大家有什么发现也来分享一下?', tags: 'GPT,Claude,数据分析,对比', circleId: circle.id, userId: demoUser.id, viewCount: 342, likeCount: 28, commentCount: 7 },
{ title: '零基础入门 AI 三个月,我做了什么', content: '三个月前我还是个 AI 小白,现在可以熟练用 AI 辅助工作。分享一下我的学习路径:\n\n**第一个月**:了解基础概念\n- 看了 Stanford HAI 的 AI Index 报告\n- 学会了写基础的 Prompt\n\n**第二个月**:开始实战\n- 用 AI 做数据分析、写周报、整理会议纪要\n- 每天至少用 AI 完成 3 个小任务\n\n**第三个月**:深入场景\n- 学习 Cursor 和 Copilot 辅助编程\n- 用 AI 做产品分析和市场调研\n\n最大的感受:不要追求一步到位,从最常用的小场景开始,慢慢扩展。', tags: '入门,学习路径,经验分享', circleId: circle.id, userId: demoUser.id, viewCount: 567, likeCount: 45, commentCount: 12 },
{ title: '2026 年最推荐的 AI 学习资源清单', content: '整理了 2026 年最值得看的 AI 学习资源:\n\n**免费课程**\n1. Stanford HAI AI Index Report(年度必读)\n2. DeepLearning.AI 的 Prompt Engineering 课程\n3. 宇之然 AI 平台的练习场(免费实战)\n\n**实用工具**\n1. ChatGPT Plus / Claude Pro(每天练手)\n2. Cursor(编程必装)\n3. Perplexity(研究必备)\n\n**社区**\n1. Reddit r/artificial\n2. 宇之然 AI 交流圈(就是这里)\n3. Devpost(参与 AI 黑客松)\n\n大家有补充的吗?', tags: '学习资源,推荐,2026', circleId: circle.id, userId: demoUser.id, viewCount: 423, likeCount: 36, commentCount: 9 },
{ title: '用 DeepSeek V4 做微调的经验分享', content: '最近在做一个垂直场景的项目,试了 DeepSeek V4 的微调,分享一些实际体验:\n\n**优点**\n- 价格真便宜,API 只有 GPT 的 1/50\n- 中文效果出乎意料的好\n- 开源可私有化部署,数据安全有保障\n\n**缺点**\n- 英文场景偶尔不如 GPT/Claude\n- 上下文 128K 对于超长文档处理有点勉强\n- 生态工具链不如 OpenAI 丰富\n\n**建议**\n适合预算有限的团队和中文场景,或者数据敏感的企业私有化部署。如果需要最强性能,还是选 GPT-5.4 或 Claude 4.6。', tags: 'DeepSeek,微调,模型,开源', circleId: circle.id, userId: demoUser.id, viewCount: 289, likeCount: 21, commentCount: 5 },
{ title: '我认为 AI 不会取代程序员,但会取代不会用 AI 的程序员', content: '这句话虽然被说烂了,但 2026 年的数据确实在验证这个观点:\n\n- Devpost 报告:参与 6 次以上黑客松的开发者,项目上线率是第一年的 2.2 倍\n- McKinsey78% 的企业已在用 AI\n- 平均每个开发者使用 2-3 个 AI 工具\n\n真正的问题不是"AI 会不会取代我",而是"怎么用 AI 放大自己的能力"\n\n1. 用 Copilot 写重复代码,把精力放在架构设计上\n2. 用 AI 做测试用例生成,把时间留给 Code Review\n3. 用 AI 写文档,让自己更专注核心逻辑\n\n建议:每天花 30 分钟探索 AI 能帮你做什么,持续 3 个月。', tags: '程序员,AI,职业发展,观点', circleId: circle.id, userId: demoUser.id, viewCount: 892, likeCount: 67, commentCount: 23 },
];
for (const post of posts) {
await prisma.post.create({ data: post });
}
console.log(`${posts.length} community posts`);
}
console.log('\n✓ Enrichment complete!');
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+136
View File
@@ -0,0 +1,136 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const skills = [
{
id: 'payment-chaser',
name: '回款助手',
description: 'AI 追款邮件生成器 — 输入发票信息,自动生成礼貌→坚定→最后的催款邮件序列',
icon: '💰',
category: 'career',
difficulty: 'beginner',
price: 19.9,
sortOrder: 10,
systemPrompt: '你是一位专业的应收账款催收专家,擅长在不损害客户关系的前提下高效追款。\n\n## 核心原则\n1. 语气逐步升级:礼貌提醒 → 友好追问 → 坚定催促 → 最后通牒\n2. 每一封邮件必须包含:发票号、金额、到期日、支付方式链接\n3. 保持专业但友好的语气,给客户留面子\n4. 提供具体的行动指引(点击链接支付、回复确认等)\n5. 英文邮件为主,可根据需要输出中文\n\n## 输出格式\n根据输入的发票信息和当前阶段,生成完整邮件(含主题行、正文、署名)。\n如果第一封未回复,自动生成第2-4封跟进邮件。\n\n用户输入格式参考:客户名称 | 发票号 | 金额 | 到期日 | 项目描述 | 当前阶段(首次/提醒/催促/最后)',
starters: JSON.stringify([
'客户张三,发票 INV-2024-001,¥5,000,已逾期7天,写一封礼貌催款邮件',
'ABC Corp, invoice INV-2024-088, $3,200, due 30 days ago, wrote once no reply, escalate',
'帮我写一个完整的催款流程:从首次提醒到最后通牒,客户是 freelancer_client,欠款 $2,400'
]),
tasks: JSON.stringify([
{ label: '首次催款', prompt: '客户王磊,发票 INV-2024-012,¥3,500,昨天到期。写第一封友好的催款提醒邮件' },
{ label: '升级催款', prompt: '客户 "TechStartup Inc"inv-2024-045$4,800,逾期14天,之前发过1次提醒未回复。写一封语气稍硬、但仍保持专业的催款邮件' },
{ label: '最后通牒', prompt: '客户 "李四工作室",发票 INV-2024-033,¥12,000,逾期45天,发过3次邮件未回复。写一封最后的正式催款通知' }
]),
tags: '催款,发票,回款,自由职业,商务邮件',
prerequisiteIds: null,
},
{
id: 'proposal-wizard',
name: '提案智造',
description: '商业提案快速生成 — 5个问题定制专业项目提案,含报价、时间线和交付物',
icon: '📋',
category: 'career',
difficulty: 'beginner',
price: 19.9,
sortOrder: 11,
systemPrompt: '你是一位资深商业提案顾问,帮助自由职业者和中小企业快速生成专业项目提案。\n\n## 核心原则\n1. 根据用户提供的项目信息,自动填充标准提案框架\n2. 提案结构:项目概述 → 需求理解 → 解决方案 → 时间线 → 报价 → 条款\n3. 强调用户价值(ROI),不只是罗列功能\n4. 报价部分要有 2-3 档选项(基础/推荐/豪华)\n5. 自动包含付款条款建议(首付50%+尾款50%)\n\n## 用户输入引导\n请依次回答以下5个问题(用户可能一次性提供全部信息,也可能需要追问):\n1. 项目名称和一句话描述?\n2. 客户是谁?行业/规模?\n3. 具体要交付什么?\n4. 时间要求?\n5. 预算范围?\n\n## 输出\n生成完整提案文本,可用 Markdown 格式直接复制到文档中。',
starters: JSON.stringify([
'帮写一个网站建设提案:客户是本地瑜伽馆,需要官网+预约系统,预算 ¥15,000-20,0003周交付',
'Write a proposal for a social media marketing retainer: client is a DTC skincare brand, $2,000/mo, 3-month minimum',
'客户是留学中介,需要一个 CRM 系统,预算 ¥30,000,2个月交付,帮我出提案'
]),
tasks: JSON.stringify([
{ label: '网站提案', prompt: '为一家本地瑜伽馆写网站建设提案:官网 + 预约系统 + 课程展示,预算 ¥15,000-20,0003周交付' },
{ label: '营销提案', prompt: '为一家DTC护肤品品牌写社媒营销提案:Instagram + TikTok 内容运营,$2,000/月,3个月起' },
{ label: '开发提案', prompt: '为一家留学中介写CRM系统开发提案:客户管理 + 申请追踪 + 通知系统,预算 ¥30,000,2个月' }
]),
tags: '提案,报价,商务,自由职业,合同',
prerequisiteIds: null,
},
{
id: 'review-intel',
name: '差评分析师',
description: '用户评论洞察 — 导入评论CSV/粘贴评论列表,自动分析差评原因、提炼改进方向',
icon: '🔍',
category: 'marketing',
difficulty: 'intermediate',
price: 19.9,
sortOrder: 12,
systemPrompt: '你是一位用户研究专家和产品分析师,擅长从用户评论中提取可执行的洞察。\n\n## 核心原则\n1. 对评论进行情感分类:正面/中性/负面\n2. 对负面评论进行主题聚类(如:发货慢、质量差、客服差、尺寸不准等)\n3. 统计每个主题的频率和严重程度\n4. 输出按优先级排序的改进建议\n5. 支持中文和英文评论混合输入\n\n## 输入格式\n用户粘贴评论列表(每行一条),或 CSV 格式。\n\n## 输出格式\n1. 📊 总览:评论总数,好评率,差评率\n2. 🔥 差评TOP主题(按频次排序)\n - 主题名:出现次数,示例评论\n3. ✅ 改进建议(按优先级)\n - 高优先级:[建议] — 预期影响\n - 中优先级:[建议]\n4. 💡 机会发现:用户隐性需求',
starters: JSON.stringify([
'分析这些亚马逊评论:Product arrived 3 days late. The material feels cheap. Zipper broke after 2 uses.',
'分析这些淘宝差评:衣服色差很大,跟图片完全不一样。尺码偏小,建议买大一码。发货太慢了等了一个星期。客服态度很差问了半天不回复',
'帮我设计一个评论分析模板,适合 Shopify 店铺批量分析'
]),
tasks: JSON.stringify([
{ label: '亚马逊评论分析', prompt: '分析以下评论:\n"Product arrived damaged. Box was crushed."\n"Love this! Perfect size and color."\n"Battery dies too fast. Not worth the money."\n"Instructions were unclear, hard to assemble."\n"Shipping was fast but product quality is mediocre."' },
{ label: '淘宝评论分析', prompt: '分析评论:衣服质量不错但味道很大晒了三天才好。跟图片一致性价比高。客服回复很慢。拉链用了两次就坏了。物流很快隔天到。尺码偏小偏小偏小说三遍' },
{ label: '综合改进建议', prompt: '假设你是一个卖蓝牙耳机的品牌,以下是用户核心差评:\n1. 续航不足(40%差评提到)\n2. 连接不稳定(25%)\n3. 佩戴舒适度差(20%)\n4. 充电口松动(15%)\n请给出按优先级排序的改进建议' }
]),
tags: '评论,用户研究,电商,产品改进,分析',
prerequisiteIds: null,
},
{
id: 'listing-optimizer',
name: '商品文案大师',
description: '跨境电商 Listing 优化 — 输入产品参数和目标关键词,生成 SEO 优化的标题、五点描述和产品描述',
icon: '🏷️',
category: 'marketing',
difficulty: 'intermediate',
price: 29.9,
sortOrder: 13,
systemPrompt: '你是一位跨境电商文案专家,精通 Amazon、Shopify、Etsy 等平台的 Listing 优化规则。\n\n## 核心原则\n1. 标题:核心关键词前置,品牌+核心词+特性+规格+使用场景\n2. 五点描述:每一点解决一个用户痛点或突出一个卖点\n3. 产品描述:讲故事,让用户想象使用场景\n4. 后台关键词:包含同义词、拼写变体、长尾词\n5. 支持 Amazon、Shopify、Etsy 三种平台格式\n\n## 输出结构\n### 平台:(Amazon / Shopify / Etsy)\n**标题**[优化后标题]\n**五点描述**\n • [卖点1]\n • [卖点2]\n ...\n**产品描述**[完整的描述段落]\n**后台关键词**:[逗号分隔的关键词]\n\n如果用户没有指定平台,默认 Amazon 格式。',
starters: JSON.stringify([
'产品:无线蓝牙耳机,核心关键词"wireless earbuds",卖点:30h续航、IPX5防水、佩戴舒适。写 Amazon listing',
'产品:瑜伽垫,关键词"yoga mat",卖点:防滑、环保TPE材质、加厚6mm。写 Etsy listing',
'产品:便携咖啡杯,350ml,双层隔热,304不锈钢,防漏。写 Amazon listing'
]),
tasks: JSON.stringify([
{ label: 'Amazon Listing', prompt: '产品:无线蓝牙耳机 (Wireless Earbuds)\n核心关键词:wireless earbuds, bluetooth 5.3, noise cancelling\n卖点:30小时续航、IPX5防水、佩戴舒适、触控操作\n写一个完整的 Amazon listing(标题+五点描述+产品描述+后台关键词)' },
{ label: 'Etsy Listing', prompt: '产品:手工真皮钱包 (Handmade Leather Wallet)\n核心关键词:leather wallet, minimalist, handmade\n卖点:手工制作、头层牛皮、RFID防盗、超薄设计\n写一个完整的 Etsy listing(标题+描述+标签)' },
{ label: 'Shopify 产品页', prompt: '产品:智能水杯 (Smart Water Bottle)\n核心关键词:smart water bottle, hydration tracker, temperature display\n卖点:LED温度显示、饮水提醒、App同步、24h保温\n写一个 Shopify 产品描述(标题+卖点+故事性描述)' }
]),
tags: '跨境电商,Amazon,listing,文案,SEO',
prerequisiteIds: null,
},
{
id: 'contract-reviewer',
name: '合同审查助手',
description: '商业合同风险识别 — 粘贴合同条款,AI 标记风险条款并给出通俗解释和修改建议',
icon: '⚖️',
category: 'business',
difficulty: 'advanced',
price: 29.9,
sortOrder: 14,
systemPrompt: '你是一位商业法律顾问,专注于帮助中小企业理解合同条款并识别风险。\n\n注意:你提供的是合同解读和风险提示,不构成正式法律意见。建议用户对重大合同咨询专业律师。\n\n## 核心原则\n1. 逐段解读合同条款,用通俗语言说明含义\n2. 标记风险等级:🔴 高风险 / 🟡 注意 / 🟢 正常\n3. 常见风险条款:自动续约、免责过宽、赔偿上限过低、管辖地不利、单方变更权\n4. 对每条风险条款给出:为什么有问题 + 建议修改方案\n5. 支持中英文合同\n\n## 输出格式\n### 📋 合同总览\n合同类型:[ ] 条款总数:[ ] 总体风险评级:[低/中/高]\n\n### 🔍 条款分析\n#### 条款[编号]:[原文摘要]\n- 风险评级:🔴/🟡/🟢\n- 白话解读:[用一句话说清楚这条什么意思]\n- 风险说明:[为什么这条对你不利]\n- 修改建议:[建议如何修改]\n\n### 📌 谈判要点\n按优先级列出3-5个必须修改的条款。',
starters: JSON.stringify([
'审查这份服务协议:服务方有权在任何时候修改条款,修改后继续使用即视为同意。服务方对任何间接损失不承担责任。',
'Review this contract clause: "Either party may terminate this agreement with 30 days written notice. Upon termination, Client shall pay all fees through the termination date." Is this fair?',
'帮我审查一份 SaaS 订阅合同中的自动续约和赔偿条款'
]),
tasks: JSON.stringify([
{ label: '服务协议审查', prompt: '审查以下条款:\n1. "服务方有权在任何时候修改服务条款,修改后通过网站公布即生效,继续使用视为接受"\n2. "服务方对因使用本服务产生的任何间接、附带或惩罚性赔偿不承担责任"\n3. "本协议适用服务方所在地法律,争议由服务方所在地法院管辖"' },
{ label: 'NDA 保密协议', prompt: '审查 NDA 条款:\n"Receiving Party may disclose Confidential Information if required by law, with prompt notice to Disclosing Party. Confidential Information excludes information that: (a) is or becomes publicly available through no fault of Receiving Party; (b) was rightfully in Receiving Party\'s possession before disclosure; (c) is independently developed by Receiving Party."' },
{ label: 'SaaS 订阅合同', prompt: '审查 SaaS 条款:\n"Subscription auto-renews unless cancelled 30 days before renewal. Fees may increase up to 10% annually. Provider may suspend service for non-payment with 5 days notice. Data will be deleted 30 days after termination. Liability cap equals 3 months of fees."' }
]),
tags: '合同,法律,风险,商务,审查',
prerequisiteIds: null,
},
];
async function main() {
for (const skill of skills) {
await prisma.skill.upsert({
where: { id: skill.id },
create: skill,
update: skill,
});
console.log(`${skill.id} (${skill.name}) — ¥${skill.price}`);
}
console.log(`\n🎉 ${skills.length} skills seeded successfully!`);
}
main()
.catch(e => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+348
View File
@@ -0,0 +1,348 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const practices = [
{
title: '客户服务邮件回复',
scenario: '你是一家电商公司的客服主管。一位客户发来邮件投诉:他购买的商品延迟了 5 天仍未发货,且客服电话一直打不通。他要求立即退款并赔偿损失。',
description: '模拟客服场景:处理客户投诉邮件,需要体现共情能力、问题解决能力和专业性。',
instructions: '请你以客服主管的身份,撰写一封回复邮件。要求:1. 首先表达歉意和理解 2. 解释原因(库存系统故障)3. 给出具体解决方案(加急发货 + 优惠券补偿)4. 保持专业礼貌的语气。邮件长度 200-300 字。',
difficulty: 'BEGINNER',
category: 'customer',
expectedCriteria: JSON.stringify([
{ name: '共情表达', maxScore: 20, description: '是否对客户的不满表达了理解和歉意' },
{ name: '解决方案', maxScore: 30, description: '是否提供了具体、可行的补偿方案' },
{ name: '专业性', maxScore: 25, description: '语气是否专业礼貌,格式是否规范' },
{ name: '完整性', maxScore: 25, description: '是否涵盖了问题说明、解决方案和后续跟进' },
]),
hint: '可以先表达感谢客户的反馈,然后说明调查结果,再给出具体补偿方案,最后承诺改进。',
sortOrder: 1,
},
{
title: 'SQL 数据查询优化',
scenario: '你是一个数据分析师。公司的订单表 orders 已有 500 万条数据。产品经理需要查询"过去 30 天内,每个分类下销售额最高的前 5 个商品"。现有查询执行需要 45 秒,严重超时。',
description: '数据库查询优化场景:分析慢查询并给出优化方案。',
instructions: '请分析以下原始 SQL 的问题,并给出优化后的查询:\n\n原始 SQL\nSELECT * FROM orders o JOIN products p ON o.product_id = p.id JOIN categories c ON p.category_id = c.id WHERE o.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY c.name, p.name ORDER BY SUM(o.amount) DESC;\n\n要求:1. 指出至少 2 个性能问题 2. 给出优化后的 SQL 3. 建议必要的索引 4. 解释优化原理',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '问题诊断', maxScore: 25, description: '是否准确指出了慢查询的根因' },
{ name: 'SQL 优化', maxScore: 35, description: '优化后的 SQL 是否正确且高效' },
{ name: '索引建议', maxScore: 25, description: '索引建议是否合理' },
{ name: '原理解释', maxScore: 15, description: '优化原理解释是否清晰' },
]),
hint: '思考:SELECT * 是否必要?GROUP BY 的字段是否都有索引?子查询或窗口函数是否更适合?',
sortOrder: 2,
},
{
title: '产品文案撰写',
scenario: '一家新兴的智能水杯创业公司需要为他们的旗舰产品撰写电商详情页文案。产品卖点:48 小时保温、智能水温显示、杯盖温度触控、500ml 容量、食品级 316 不锈钢、支持 APP 记录饮水量。目标用户是 25-40 岁的都市白领。',
description: '电商文案写作场景:针对目标用户撰写有说服力的产品文案。',
instructions: '请撰写一段产品详情页的"核心卖点"部分文案(300-400 字)。要求:1. 突出 2-3 个核心差异化卖点 2. 针对都市白领的痛点(忙碌、注重健康、追求品质)3. 使用有画面感的语言 4. 包含一个吸引人的标题。',
difficulty: 'BEGINNER',
category: 'writing',
expectedCriteria: JSON.stringify([
{ name: '卖点提炼', maxScore: 25, description: '是否准确提炼了核心差异化卖点' },
{ name: '用户洞察', maxScore: 25, description: '是否针对目标用户的痛点和需求' },
{ name: '语言表达', maxScore: 30, description: '语言是否生动有感染力' },
{ name: '结构逻辑', maxScore: 20, description: '文案结构是否清晰有说服力' },
]),
hint: '想想都市白领的一天:早上匆忙出门需要热水、下午开会时想喝温水、晚上健身需要补水。从场景出发更容易打动人心。',
sortOrder: 3,
},
{
title: 'Python 数据分析脚本',
scenario: '市场部给你一份 CSV 文件(sales_2024.csv),包含全年销售数据:日期、产品、区域、销售额、数量。市场总监需要一份按月的各区域销售趋势报表,同时标识出同比增长超过 20% 的明星产品。',
description: '数据分析编程场景:用 Python 处理真实业务数据。',
instructions: '请编写一个 Python 脚本来完成以下任务:\n1. 读取 CSV 并按月份和区域汇总销售额\n2. 计算每个产品相对于去年同期的增长率\n3. 标记增长率 > 20% 的产品为"明星产品"\n4. 生成一个可视化图表(按月/区域的热力图)\n5. 输出汇总报表到 Excel\n\n要求:使用 pandas + matplotlib,代码要有注释,考虑异常处理。',
difficulty: 'ADVANCED',
category: 'coding',
expectedCriteria: JSON.stringify([
{ name: '功能完整性', maxScore: 30, description: '是否完成了所有要求的 5 个任务' },
{ name: '代码质量', maxScore: 25, description: '代码是否规范、可读性强、有注释' },
{ name: '数据处理', maxScore: 25, description: '数据聚合和计算逻辑是否正确' },
{ name: '异常处理', maxScore: 20, description: '是否考虑了文件缺失、数据格式等异常' },
]),
hint: '使用 pd.read_csv() 读取,pd.to_datetime() 处理日期,df.pivot_table() 做透视表。同比增长需要先按年月分组,然后用 shift() 获取同期数据。',
sortOrder: 4,
},
{
title: 'Prompt 优化:简历筛选助手',
scenario: '你是一个 HR 技术经理。公司要招聘一名高级前端工程师,你准备让 AI 助手帮忙初步筛选候选人简历。但你发现直接用"帮我筛选简历"得到的回复非常笼统,不够专业。',
description: '提示词工程场景:设计一个专业、精准的简历筛选 Prompt。',
instructions: '请设计一个用于 AI 简历筛选的系统级提示词(System Prompt)。要求:\n1. 设定 AI 的角色为"资深前端技术面试官"\n2. 明确评估维度(技术栈匹配度、项目经验质量、工作年限、教育背景、软技能)\n3. 要求 AI 按 5 分制评分并给出具体理由\n4. 设置输出格式为结构化 JSON\n5. 给出一个使用你设计的 Prompt 筛选简历的示例输出',
difficulty: 'INTERMEDIATE',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: '角色设定', maxScore: 20, description: '角色设定是否清晰专业' },
{ name: '评估维度', maxScore: 25, description: '评估维度是否全面合理' },
{ name: '评分标准', maxScore: 25, description: '评分标准是否可量化、可操作' },
{ name: '输出格式', maxScore: 30, description: '输出格式是否结构化、便于后续处理' },
]),
hint: '好的 System Prompt 包含:角色 + 任务 + 要求 + 输出格式 + 约束条件。行业最佳实践是用 COSTAR 框架(Context, Objective, Style, Tone, Audience, Response)。',
sortOrder: 5,
},
{
title: 'A/B 测试方案设计',
scenario: '你们产品的注册转化率目前是 2.3%。产品经理建议将注册按钮从"注册"改为"免费开始使用",并猜测这样可以提升转化率到 3.5%。CEO 要求你设计一个完整的 A/B 测试方案来验证这个假设。',
description: '产品分析场景:设计严谨的 A/B 测试方案验证业务假设。',
instructions: '请设计一个完整的 A/B 测试方案。要求包含:\n1. 明确的原假设和备择假设\n2. 样本量计算(α=0.05, β=0.2)\n3. 实验分组策略(流量分配、随机化方法)\n4. 实验周期和决策标准\n5. 需要考虑的潜在偏差和缓解措施\n6. 评估指标(主指标 + 辅助指标 + 护栏指标)',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '假设检验', maxScore: 20, description: '假设设定是否准确,样本量计算是否正确' },
{ name: '实验设计', maxScore: 30, description: '分组策略、周期和决策标准是否科学' },
{ name: '偏差控制', maxScore: 25, description: '是否识别了关键偏差并给出缓解方案' },
{ name: '指标体系', maxScore: 25, description: '指标体系是否完整(主/辅/护栏)' },
]),
hint: '样本量公式:n = (Z_α/2 + Z_β)² * (p1(1-p1) + p2(1-p2)) / (p2-p1)²。注意需要考虑新奇效应(Novelty Effect)和交互效应。',
sortOrder: 6,
},
{
title: '技术方案设计:短链接服务',
scenario: '公司需要内部搭建一个短链接服务,用于营销短信中的链接跳转。预计 QPS 峰值 5000,需要支持链接点击统计和过期时间设置。你作为后端技术负责人需要输出技术方案。',
description: '系统设计场景:设计一个高可用的短链接服务架构。',
instructions: '请设计一个短链接服务的技术方案。要求包含:\n1. 架构图描述(组件、数据流)\n2. 短链接生成算法(考虑冲突和性能)\n3. 数据库表结构设计\n4. 重定向流程(301 vs 302 的选择)\n5. 高可用方案(缓存、容灾)\n6. 点击统计的实现方案',
difficulty: 'ADVANCED',
category: 'coding',
expectedCriteria: JSON.stringify([
{ name: '架构设计', maxScore: 25, description: '架构是否合理,组件划分是否清晰' },
{ name: '算法设计', maxScore: 20, description: '短链生成算法是否高效、无冲突' },
{ name: '数据存储', maxScore: 20, description: '表结构设计和缓存策略是否合理' },
{ name: '高可用', maxScore: 20, description: '高可用和容灾方案是否完善' },
{ name: '统计方案', maxScore: 15, description: '点击统计方案是否准确可靠' },
]),
hint: '短链生成可以用 62 进制编码(a-z A-Z 0-9),也可以用雪花算法取后 7 位。读请求用 Redis 缓存,写请求通过 MQ 异步落库。',
sortOrder: 7,
},
{
title: '创意营销:AI 产品上市方案',
scenario: '公司即将推出一款 AI 写作助手产品"文思",目标用户是自媒体创作者和文案从业者。核心功能:AI 写文章、智能改写、多平台适配排版、一键生成配图。竞品有 Jasper 和 Copy.ai。',
description: '创意营销场景:设计 AI 产品的上市推广方案。',
instructions: '请设计"文思"产品的上市营销推广方案。要求:\n1. 核心卖点提炼(3 个以内,一句话一个)\n2. 目标用户画像(至少 2 类)\n3. 定价策略建议\n4. 冷启动推广渠道和内容策略\n5. 病毒传播机制设计\n6. 写出一个社交媒体推广文案示例(140 字以内)',
difficulty: 'INTERMEDIATE',
category: 'creative',
expectedCriteria: JSON.stringify([
{ name: '卖点提炼', maxScore: 20, description: '卖点是否精准差异化' },
{ name: '用户洞察', maxScore: 20, description: '用户画像是否真实有深度' },
{ name: '推广策略', maxScore: 30, description: '冷启动和病毒传播机制是否有创意可执行' },
{ name: '文案示例', maxScore: 30, description: '文案是否有感染力、符合社交媒体调性' },
]),
hint: '考虑用"AI 写作合伙人"的概念替代"AI 工具"——后者让创作者感到威胁,前者让他们感到被赋能。可以设计"AI 写初稿,你写爆款"的传播概念。',
sortOrder: 8,
},
// === 行业专题练习(2026 年真实场景)===
{
title: '金融风控:AI 反欺诈规则设计',
scenario: '你是一家银行的风控分析师。2026 年第一季度,该行信用卡交易欺诈损失同比增长 37%,主要来自新型的"合成身份欺诈"——犯罪分子用真实信息碎片拼凑虚假身份开户。管理层要求你用 AI 设计一套实时反欺诈规则引擎。',
description: '金融风控场景:设计基于 AI 的实时交易反欺诈规则。',
instructions: '请设计一套 AI 反欺诈规则引擎方案。要求:\n1. 识别至少 4 个合成身份欺诈的行为特征(如设备指纹、交易模式、社交图谱等)\n2. 设计多层规则架构(规则层 + 模型层 + 决策层)\n3. 描述实时风控流程(交易发起 → 特征提取 → 模型评分 → 决策输出)\n4. 提出模型训练数据的特征工程方案\n5. 考虑误杀率(False Positive)控制策略——拒绝正常交易会影响客户体验和收入',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '欺诈特征识别', maxScore: 20, description: '是否准确识别了合成身份欺诈的关键行为特征' },
{ name: '架构设计', maxScore: 25, description: '多层规则架构是否合理完整' },
{ name: '实时流程', maxScore: 25, description: '实时风控流程描述是否清晰可行' },
{ name: '误杀控制', maxScore: 30, description: '误杀率控制策略是否具体有效' },
]),
hint: '合成身份欺诈的特征包括:设备指纹异常(虚拟机/模拟器)、社交关系稀疏(0-3 个联系人)、交易金额模式异常(小额测试→大额盗刷)。考虑使用 Graph Neural Network 检测社交图谱异常。2025 年美联储报告显示合成身份欺诈占信用卡欺诈的 20%。',
sortOrder: 9,
},
{
title: '医疗场景:AI 临床文书生成',
scenario: '你在一家三甲医院的信息化部门工作。2026 年国家卫健委要求三级医院电子病历评级达到 5 级以上。医生普遍抱怨录入病历占用大量时间——每位门诊医生日均花费 2.5 小时写病历。医院决定引入 AI 临床文书系统,将医患对话自动转写成结构化病历。',
description: '医疗信息化场景:设计 AI 辅助临床文书系统。',
instructions: '请设计 AI 临床文书系统的 Prompt 方案。要求:\n1. 设计一个 System Prompt,将口语化医患对话转写为标准 SOAPSubjective, Objective, Assessment, Plan)格式病历\n2. 给出一个示例对话(医生问诊高血压患者)和转写结果\n3. 说明如何处理关键问题:医学术语标准化、隐私信息脱敏、非结构化口语的消歧\n4. 设计质控机制——AI 生成的病历如何由医生审核确认',
difficulty: 'ADVANCED',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: 'Prompt 设计', maxScore: 25, description: 'System Prompt 是否完整覆盖 SOAP 四部分' },
{ name: '示例质量', maxScore: 25, description: '示例对话和转写结果是否专业准确' },
{ name: '技术难点', maxScore: 25, description: '术语标准化、脱敏、消歧方案是否可行' },
{ name: '质控机制', maxScore: 25, description: '审核确认流程是否合理合规' },
]),
hint: '参考 10-20-70 AI 落地法则:10% 算法、20% 技术工程、70% 组织变革。医生的接受度是关键瓶颈。建议设计"AI 起草 + 医生审核"模式,而非全自动。病历要用 SNOMED CT 或 ICD-11 标准化术语。',
sortOrder: 10,
},
{
title: '电商推荐:AI 个性化引擎设计',
scenario: '你是一家年 GMV 50 亿元的跨境电商平台的数据科学负责人。当前推荐系统基于协同过滤,新品 7 日曝光率仅 12%,冷启动问题严重。2026 年 70% 的电商高管将 AI 个性化列为首要投资方向。CEO 要求你设计新一代 AI 推荐引擎。',
description: '电商推荐场景:设计基于多模态 AI 的个性化推荐引擎。',
instructions: '请设计新一代 AI 推荐引擎方案。要求:\n1. 架构设计:数据层 → 特征工程 → 召回 → 排序 → 重排 → 混排\n2. 解决冷启动问题的具体方案(新品、新用户、新场景)\n3. 多模态特征融合方案(文本描述、商品图片、用户行为序列)\n4. 实时个性化策略(基于当前 Session 行为的实时调整)\n5. A/B 测试评估指标体系',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '架构完整性', maxScore: 20, description: '各层组件和功能是否完整' },
{ name: '冷启动方案', maxScore: 30, description: '冷启动方案是否具体可行' },
{ name: '多模态融合', maxScore: 25, description: '多模态特征融合方案是否技术合理' },
{ name: '评估体系', maxScore: 25, description: 'A/B 测试指标体系是否科学完整' },
]),
hint: '冷启动可以用内容特征(CLIP 提取图像向量)+ 知识图谱(品类关联)+ Exploration 策略(epsilon-greedy 或 Thompson Sampling)。多模态融合推荐使用两阶段:先 image embedding 聚类,再 user behavior sequence 排序。',
sortOrder: 11,
},
{
title: 'HR 招聘:AI 筛选偏见审计',
scenario: '你是一家千人规模科技公司的 HR 技术负责人。2025 年公司引入 AI 简历筛选系统后,算法岗女性候选人进入面试的比例从 38% 骤降到 12%。初步排查发现,模型因为历史数据中男性工程师占比 85%,学到了"男性更合适"的偏见。现在法律部门要求你出具审计报告并修复问题。',
description: 'HR 与 AI 伦理场景:识别并修复 AI 招聘中的偏见问题。',
instructions: '作为 AI 伦理审计负责人,请完成以下任务:\n1. 分析 AI 简历筛选系统产生性别偏见的根因(至少 3 个)\n2. 设计偏见量化评估方法(用什么指标衡量偏见程度)\n3. 提出至少 4 个具体的修复方案\n4. 设计持续监控机制——上线后如何确保偏见不再复发\n5. 起草一份给管理层的摘要说明(200 字以内),解释问题、影响和解决方案',
difficulty: 'INTERMEDIATE',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: '根因分析', maxScore: 20, description: '偏见根因分析是否全面深入' },
{ name: '量化评估', maxScore: 25, description: '偏见量化指标是否科学合理' },
{ name: '修复方案', maxScore: 30, description: '修复方案是否具体、可落地' },
{ name: '沟通表达', maxScore: 25, description: '管理层摘要是否清晰有说服力' },
]),
hint: '偏见来源:历史数据偏差(历史 hire 数据中男女比例失衡)、特征选择偏差(gap in employment 对女性不利)、标注偏差(面试官主观偏好)。修复方法:对抗性去偏(Adversarial Debiasing)、重采样、公平性约束(Demographic Parity / Equal Opportunity)。用 "Demographic Parity Difference" < 0.1 作为公平性阈值。',
sortOrder: 12,
},
{
title: '教育科技:AI 一对一辅导 Prompt 设计',
scenario: '你是一家 EdTech 公司的 AI 教育产品经理。2026 年 Digital Education Council 报告显示 86% 的大学生使用 AI 辅助学习,但 80% 的大学仍未制定 AI 使用政策。你的公司要推出一款 AI 一对一辅导系统,能根据学生水平和学习风格自适应调整教学策略,定位为"大学官方合作的合规 AI 辅导工具"。',
description: '教育科技场景:设计自适应 AI 辅导的系统 Prompt。',
instructions: '请为一个 AI 数学辅导系统设计核心 Prompt 和工作流程。要求:\n1. 设计 System Prompt 使 AI 能扮演耐心的高中数学老师\n2. 实现自适应策略:根据学生水平(基础/中等/拔高)调整讲解深度和例题难度\n3. 设计苏格拉底式教学法——不直接给答案,而是通过提问引导学生思考\n4. 包含错误分析机制——当学生答错时,识别错误类型(概念混淆/计算失误/审题不清)并针对性讲解\n5. 给出一个教学对话示例(主题:一元二次方程)',
difficulty: 'INTERMEDIATE',
category: 'creative',
expectedCriteria: JSON.stringify([
{ name: 'Prompt 设计', maxScore: 20, description: 'System Prompt 是否体现了耐心老师的角色' },
{ name: '自适应策略', maxScore: 25, description: '自适应策略是否清晰、可执行' },
{ name: '教学方法', maxScore: 30, description: '苏格拉底教学法和错误分析机制是否有效' },
{ name: '示例质量', maxScore: 25, description: '教学对话示例是否自然且体现设计要点' },
]),
hint: '可以用"Level 1/2/3"三级自适应:Level 1 多步骤引导、具象化类比;Level 2 核心概念讲解 + 中等难度;Level 3 给出思考方向后让学习者自己推导。错误分析用 Chain-of-Thought 识别类型,再匹配对应的教学策略。参考 Khan Academy 的 "Mastery Learning" 理念。',
sortOrder: 13,
},
{
title: '法律合规:GDPR 与 AI 法案合规审计',
scenario: '你的公司开发了一款 AI 面试助手,用于自动化筛选候选人并生成面试报告。2026 年欧盟 AI 法案已全面生效,你的产品被分类为"高风险 AI 系统"。法务部门要求你出具合规差距分析报告,确保产品满足 GDPR 和 AI 法案要求,否则将面临最高全球营收 6% 的罚款。',
description: '法律合规场景:AI 产品合规与数据保护审计。',
instructions: '请对 AI 面试助手进行合规差距分析。要求:\n1. 识别高风险 AI 系统的合规要求(透明性、人类监督、准确性、公平性、数据治理)\n2. 设计合规检查清单(至少 8 项)\n3. 给出数据处理方案:候选人数据如何收集、存储、保留、删除\n4. 设计"被遗忘权"实现方案——候选人要求删除数据时的技术流程\n5. 设计透明度声明模板——向候选人说明 AI 如何参与招聘决策',
difficulty: 'INTERMEDIATE',
category: 'general',
expectedCriteria: JSON.stringify([
{ name: '合规要求识别', maxScore: 25, description: '是否准确识别了高风险 AI 系统关键合规要求' },
{ name: '检查清单', maxScore: 25, description: '检查清单是否全面可操作' },
{ name: '数据处理', maxScore: 25, description: '数据治理方案是否符合 GDPR 原则' },
{ name: '透明度', maxScore: 25, description: '透明度声明是否清晰、对候选人友好' },
]),
hint: 'AI 法案高风险要求包括:建立风险管理系统、使用代表性训练数据、记录自动日志、确保人类审查、达到准确性和公平性标准。GDPR 第 22 条赋予公民"不受自动化决策约束"的权利。被遗忘权技术实现:级联删除 + 匿名化 + 模型重训(machine unlearning 尚不成熟,可用排除列表替代)。',
sortOrder: 14,
},
{
title: '产品管理:AI 驱动的需求优先级排序',
scenario: '你是一家 SaaS 平台的产品经理。季度规划会议上,你收集到 15 个待开发功能需求,但研发团队只有 50 人/周产能。销售部要求做大客户定制功能,客服部要求优化工单系统,用户反馈要求移动端适配。CEO 要求用数据驱动的方法精准排定优先级。',
description: '产品管理场景:用 AI 辅助数据分析进行需求优先级排序。',
instructions: '请设计方案,用数据驱动的方法给 15 个需求排定优先级。要求:\n1. 设计量化评分模型(至少 4 个维度:用户价值、商业价值、实现成本、战略对齐)\n2. 说明每个维度的数据和量化方法\n3. 设计 AI 辅助分析流程——如何利用 AI 分析用户反馈数据提取关键需求\n4. 给出 RICE 评分框架的具体实现(Reach, Impact, Confidence, Effort\n5. 讨论如何处理"高价值但高风险"的需求决策',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '评分模型', maxScore: 25, description: '量化评分模型维度是否全面合理' },
{ name: '数据量化', maxScore: 25, description: '各维度的数据来源和量化方法是否可行' },
{ name: 'AI 辅助', maxScore: 25, description: 'AI 分析用户反馈的流程是否有效' },
{ name: '决策讨论', maxScore: 25, description: '高风险需求的处理策略是否合理' },
]),
hint: 'RICE 是成熟的优先级框架:Reach(影响用户数/季度)、Impact(转化率提升估算)、Confidence(数据支撑度 %)、Effort(人周)。AI 辅助:用 LLM 对用户反馈进行主题聚类 + 情感分析 + 需求提取,然后映射到功能需求池。高价值高风险用"最小可行实验"(MVE)验证后再承诺。',
sortOrder: 15,
},
{
title: '运营增长:AI 驱动用户留存策略',
scenario: '你的社交电商 APP 在 2025 年底获客成本(CAC)涨到 128 元/人,但 30 日留存率只有 18%。投资人要求优化留存而非盲目获客。你决定设计一套 AI 驱动的用户留存系统,在用户可能流失前进行精准干预。',
description: '运营增长场景:设计 AI 驱动的用户流失预警与留存方案。',
instructions: '请设计 AI 用户留存系统方案。要求:\n1. 构建用户流失预测模型——识别至少 5 个关键预警信号(行为特征)\n2. 设计分层干预策略(轻度风险→推送优惠券,中度风险→人工回访,重度风险→专属福利)\n3. 设计个性化留存动作——根据用户画像匹配不同的激励方式\n4. 设计 uplift 模型衡量干预的增量效果(反事实推断)\n5. 给出 A/B 测试框架验证留存策略有效性',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '预警信号', maxScore: 20, description: '流失预警信号是否全面、可量化' },
{ name: '干预策略', maxScore: 30, description: '分层干预策略是否合理、可执行' },
{ name: '个性化', maxScore: 25, description: '个性化留存方案是否精准有效' },
{ name: '实验验证', maxScore: 25, description: 'uplift 模型和 A/B 测试框架是否科学' },
]),
hint: '流失预警信号:登录频率下降(间隔>5天)、核心行为减少(浏览商品数下降 50%)、负反馈增加(投诉/退单)、社交互动减少(点赞评论归零)、竞品活跃度。Uplift Model 用 Two-Model 方法或 Class Transformation Method。用 "Incremental Lift" 而非「转化率提升」衡量效果。',
sortOrder: 16,
},
{
title: '供应链管理:AI 需求预测与库存优化',
scenario: '你是一家跨境电商公司的供应链总监。公司经营 5000+ SKU,2025 年因库存管理不善造成 2300 万元滞销损失,同时有 140 个爆款 SKU 出现断货。2026 年全球供应链波动加剧,管理层要求引入 AI 驱动的需求预测系统,将库存周转率提升 30%。',
description: '供应链场景:构建 AI 驱动的需求预测与智能补货系统。',
instructions: '请设计 AI 需求预测系统方案。要求:\n1. 预测模型方案:时间序列特征工程策略(季节性、趋势、促销事件、外部因素)\n2. 多层级预测架构:SKU 级 → 品类级 → 仓库级,如何保证一致性\n3. 智能补货策略:动态安全库存计算(考虑交货周期波动)\n4. 滞销品处理机制:识别滞销品并给出清仓建议(降价幅度、促销渠道)\n5. 数据质量保障方案:如何处理历史数据中的缺失值和异常值',
difficulty: 'ADVANCED',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '特征工程', maxScore: 20, description: '时间序列特征是否全面合理' },
{ name: '层级架构', maxScore: 25, description: '多层级预测一致性方案是否可行' },
{ name: '补货策略', maxScore: 30, description: '动态安全库存计算和补货策略是否合理' },
{ name: '数据质量', maxScore: 25, description: '数据质量保障方案是否具体有效' },
]),
hint: '多层级一致性用 "Top-Down Reconciliation"(按历史比例拆分)或 "Middle-Out" 方法。安全库存公式:SS = Z × σ_d × √L(Z=服务水平系数,σ_d=需求标准差,L=交货周期)。用 Prophet 或 DeepAR 做基础预测。缺失值处理:时间点用插值,序列头尾用回填。滞销品按 "库存天数 > 90 天且周销量 < 5" 定义。',
sortOrder: 17,
},
{
title: 'B2B 销售:AI 销售线索评分模型',
scenario: '你是一家企业 SaaS 公司的市场总监。市场部每月从官网、展会、广告等渠道获取 3000+ 销售线索,但销售团队只有 15 人,只能跟进其中 30%。目前线索分配靠销售主管手动判断,优质线索跟进不及时,低质线索浪费销售精力。公司要求将线索到签约的转化率提升一倍。',
description: 'B2B 销售场景:用 AI 构建销售线索评分与自动分配系统。',
instructions: '请设计 AI 销售线索评分与分配系统。要求:\n1. 线索评分模型:定义至少 6 个评分特征(如公司规模、决策者级别、行为意图等)\n2. 评分算法:设计打分规则(加权评分 / 逻辑回归 / 机器学习模型)\n3. 线索分配规则:根据评分和销售专长自动分配\n4. 反馈闭环:如何利用历史转化数据优化评分模型\n5. 设计一个评分报表 Dashboard 方案——销售主管看到什么',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '评分特征', maxScore: 25, description: '评分特征是否全面反映购买意向' },
{ name: '评分算法', maxScore: 25, description: '评分算法是否合理可解释' },
{ name: '分配机制', maxScore: 25, description: '线索分配规则是否合理、公平' },
{ name: '反馈闭环', maxScore: 25, description: '模型优化反馈闭环是否完整' },
]),
hint: 'BANT 模型(Budget, Authority, Need, Timeline)是经典线索评估框架。行为意图信号:下载白皮书、访问定价页、参与 Demo 预约、打开营销邮件≥3 次。评分可用 Logistic Regression(可解释性好),输出 0-100 分。80 分以上自动分配给 Top Sales60-80 分分配给 Inside Sales60 分以下进入培育流程。',
sortOrder: 18,
},
{
title: '内容创作:AI 视频脚本与数字人方案',
scenario: '你是一家 MCN 机构的内容策略总监。2026 年短视频流量红利见顶,纯人工创作产能不足——一个 3 人团队每周最多产出 5 条视频。公司决定引入 AI 数字人主播 + AI 脚本生成,在保持内容质量的前提下将产能提升 5 倍。老板让你设计完整方案。',
description: '内容创作场景:设计 AI 数字人主播生产流程与脚本 Prompt。',
instructions: '请设计 AI 数字人内容生产方案。要求:\n1. 设计 AI 脚本生成 Prompt——生成 60 秒口播短视频脚本(主题:AI 工具推荐),包含爆款开头、核心内容、CTA\n2. 描述从脚本到视频的完整生产流程\n3. 如何保证数字人内容的"人感"——避免 AI 味太重\n4. 内容质量控制机制——如何确保品牌调性一致\n5. A/B 测试策略——如何用数据优化 AI 内容的完播率和转化率',
difficulty: 'INTERMEDIATE',
category: 'creative',
expectedCriteria: JSON.stringify([
{ name: 'Prompt 设计', maxScore: 25, description: '脚本 Prompt 能否生成高质量口播脚本' },
{ name: '生产流程', maxScore: 20, description: '生产流程是否完整可执行' },
{ name: '人感策略', maxScore: 30, description: '避免 AI 味的策略是否有效' },
{ name: '优化机制', maxScore: 25, description: 'A/B 测试和质量控制机制是否合理' },
]),
hint: '短视频爆款开头公式:前 3 秒制造冲突/悬念/共鸣。"人感"策略:加入语气词(嗯、其实、我跟你说)、节奏变化(快慢交替)、真实的微表情(数字人加入眨眼和头部微动)。数字人主播选择要考虑目标受众偏好——2026 年数据显示 35 岁以下用户更喜欢二次元风格数字人。',
sortOrder: 19,
},
{
title: 'AI 安全:企业 Shadow AI 治理方案',
scenario: '你是一家金融科技公司的 CISO(首席信息安全官)。2026 年 Microsoft 报告显示 75% 的员工在使用未授权的 AI 工具处理工作,其中 48% 将公司数据粘贴到公共 AI 平台。上个月,一名员工将包含客户 PII 数据的代码片段粘贴到公共 ChatGPT,导致合规事件。CEO 要求你制定 Shadow AI 治理方案。',
description: 'AI 安全与治理场景:企业 Shadow AI(影子 AI)风险管控方案。',
instructions: '请设计企业 Shadow AI 治理方案。要求:\n1. 风险识别:列出至少 5 个 Shadow AI 使用场景及风险等级\n2. 技术管控方案:如何检测和阻止敏感数据流向未经授权的 AI 平台\n3. 合规方案:设计企业 AI 使用政策,明确哪些数据可以/不可以输入 AI\n4. 员工培训策略:如何让员工理解 AI 安全风险(而非简单地封禁)\n5. 替代方案:如何提供"安全的企业 AI 工具"让员工不需要用外面的 AI',
difficulty: 'INTERMEDIATE',
category: 'analysis',
expectedCriteria: JSON.stringify([
{ name: '风险识别', maxScore: 25, description: 'Shadow AI 风险场景是否识别全面' },
{ name: '技术管控', maxScore: 25, description: '技术管控方案是否有效可落地' },
{ name: '合规政策', maxScore: 25, description: 'AI 使用政策是否明确合理' },
{ name: '替代方案', maxScore: 25, description: '企业安全 AI 替代方案是否可行' },
]),
hint: '检测方案:DLP(数据防泄漏)监控出站流量中的 API 调用特征(已知 AI 平台域名+IP)、浏览器扩展检测、网络代理日志分析。Block 方案:在网关层阻止对未授权 AI 域名的访问。替代方案的核心是数据隔离——企业内部部署的 AI 不会将数据用于模型训练。参考 Cisco 2026 数据隐私基准研究。',
sortOrder: 20,
},
];
async function main() {
console.log('Seeding practice questions...');
await prisma.practiceQuestion.deleteMany();
for (let i = 0; i < practices.length; i++) {
const p = practices[i];
await prisma.practiceQuestion.create({
data: { ...p, sortOrder: i + 1 },
});
console.log(`${p.title}`);
}
console.log(`Seeded ${practices.length} practice questions.`);
}
main()
.catch(e => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+15
View File
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
@@ -22,10 +24,19 @@ import { NotificationModule } from './modules/notifications/notification.module'
import { LearningModule } from './modules/learning/learning.module';
import { SkillsModule } from './modules/skills/skills.module';
import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
import { PracticesModule } from './modules/practices/practices.module';
import { RedisThrottlerStorage } from './common/redis-throttler-storage';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 60000, limit: 60 },
{ name: 'medium', ttl: 300000, limit: 200 },
],
storage: new RedisThrottlerStorage(),
}),
PrismaModule,
AuthModule, UsersModule, CoursesModule, ContentsModule,
PromptsModule, ToolsModule, SandboxModule, OrdersModule,
@@ -33,6 +44,10 @@ import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module';
SearchModule, ModelsModule, UploadModule, CommunityModule,
EnterpriseModule, NotificationModule, LearningModule, SkillsModule,
AiAssistantModule,
PracticesModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class AppModule {}
@@ -0,0 +1,35 @@
import { ThrottlerStorage } from '@nestjs/throttler';
import Redis from 'ioredis';
export class RedisThrottlerStorage implements ThrottlerStorage {
private redis: Redis;
constructor() {
this.redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
keyPrefix: 'throttle:',
});
}
async increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise<{
totalHits: number;
timeToExpire: number;
isBlocked: boolean;
timeToBlockExpire: number;
}> {
const redisKey = `throttle:${throttlerName}:${key}`;
const total = await this.redis.incr(redisKey);
if (total === 1) {
await this.redis.pexpire(redisKey, ttl);
}
const ttlRemaining = await this.redis.pttl(redisKey);
return {
totalHits: total,
timeToExpire: Math.max(0, Math.ceil(ttlRemaining / 1000)),
isBlocked: total > limit,
timeToBlockExpire: total > limit ? Math.max(0, Math.ceil(ttlRemaining / 1000)) : 0,
};
}
}
+32 -2
View File
@@ -5,6 +5,7 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import helmet from 'helmet';
import * as compression from 'compression';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { GlobalExceptionFilter, LoggingInterceptor } from './common';
@@ -15,14 +16,36 @@ async function bootstrap() {
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'https:'],
styleSrc: ["'self'", "'unsafe-inline'", 'https:'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'https:'],
fontSrc: ["'self'", 'https:'],
objectSrc: ["'none'"],
frameSrc: ["'self'"],
},
},
}));
app.use(cookieParser());
app.use(compression());
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads' });
app.setGlobalPrefix('api/v1');
const allowedOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['https://yuzhiran.com', 'https://www.yuzhiran.com', 'https://www.yuzhiran.com.cn'];
app.enableCors({
origin: true,
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(null, false);
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
@@ -49,4 +72,11 @@ async function bootstrap() {
console.log(`API 文档: http://localhost:${port}/api/docs`);
}
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});
bootstrap();
+250 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param, Query, HttpException, HttpStatus } from '@nestjs/common';
import { Controller, Post, Get, Put, Delete, Body, UseGuards, Req, Param, Query, HttpException, HttpStatus, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminService } from './admin.service';
@@ -232,4 +232,253 @@ export class AdminController {
return { reply: result.summary, result };
}
}
// --- Practice Questions Admin ---
@Get('practices')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPractices(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('search') search?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const where: any = {};
if (search) where.title = { contains: search };
const [items, total] = await Promise.all([
this.prisma.practiceQuestion.findMany({ where, orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.practiceQuestion.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('practices/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPractice(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.practiceQuestion.findUnique({ where: { id } });
if (!item) throw new HttpException('练习题目不存在', HttpStatus.NOT_FOUND);
return item;
}
@Post('practices')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async createPractice(@Body() body: any) {
const item = await this.prisma.practiceQuestion.create({ data: body });
return item;
}
@Put('practices/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updatePractice(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
const { id: _id, ...data } = body;
const item = await this.prisma.practiceQuestion.update({ where: { id }, data });
return item;
}
@Delete('practices/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deletePractice(@Param('id', ParseIntPipe) id: number) {
await this.prisma.practiceQuestion.delete({ where: { id } });
return { ok: true };
}
// --- Skills Admin ---
@Get('skills')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminSkills(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const [items, total] = await Promise.all([
this.prisma.skill.findMany({ orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.skill.count(),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('skills/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminSkill(@Param('id') id: string) {
const item = await this.prisma.skill.findUnique({ where: { id } });
if (!item) throw new HttpException('技能不存在', HttpStatus.NOT_FOUND);
return item;
}
@Put('skills/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateSkill(@Param('id') id: string, @Body() body: any) {
const { id: _id, purchasedBy, ...data } = body;
const item = await this.prisma.skill.update({ where: { id }, data });
return item;
}
// --- Circles Admin ---
@Get('circles')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminCircles(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const [items, total] = await Promise.all([
this.prisma.circle.findMany({ orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps, include: { creator: { select: { id: true, nickname: true } }, _count: { select: { members: true, posts: true } } } }),
this.prisma.circle.count(),
]);
return { items, total, page: p, pageSize: ps };
}
@Delete('circles/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deleteCircle(@Param('id', ParseIntPipe) id: number) {
await this.prisma.circle.delete({ where: { id } });
return { ok: true };
}
// --- Community Posts Admin ---
@Get('posts')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPosts(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('status') status?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const where: any = {};
if (status) where.status = status;
const [items, total] = await Promise.all([
this.prisma.post.findMany({
where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps,
include: { user: { select: { id: true, nickname: true } }, _count: { select: { comments: true, likes: true } } },
}),
this.prisma.post.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('posts/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminPost(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.post.findUnique({
where: { id },
include: { user: { select: { id: true, nickname: true } }, _count: { select: { comments: true, likes: true } } },
});
if (!item) throw new HttpException('帖子不存在', HttpStatus.NOT_FOUND);
return item;
}
@Put('posts/:id/status')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updatePostStatus(@Param('id', ParseIntPipe) id: number, @Body() body: { status: string }) {
const item = await this.prisma.post.update({ where: { id }, data: { status: body.status } });
return item;
}
@Delete('posts/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deletePost(@Param('id', ParseIntPipe) id: number) {
await this.prisma.post.delete({ where: { id } });
return { ok: true };
}
// --- AI Tools Admin ---
@Get('tools')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminTools(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('search') search?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const where: any = {};
if (search) where.name = { contains: search };
const [items, total] = await Promise.all([
this.prisma.tool.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.tool.count({ where }),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('tools/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminTool(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.tool.findUnique({ where: { id } });
if (!item) throw new HttpException('工具不存在', HttpStatus.NOT_FOUND);
return item;
}
@Post('tools')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async createTool(@Body() body: any) {
const item = await this.prisma.tool.create({ data: body });
return item;
}
@Put('tools/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateTool(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
const { id: _id, ...data } = body;
const item = await this.prisma.tool.update({ where: { id }, data });
return item;
}
@Delete('tools/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deleteTool(@Param('id', ParseIntPipe) id: number) {
await this.prisma.tool.delete({ where: { id } });
return { ok: true };
}
// --- AI Models Admin ---
@Get('models')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminModels(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 20));
const [items, total] = await Promise.all([
this.prisma.aiModel.findMany({ orderBy: { sortOrder: 'asc' }, skip: (p - 1) * ps, take: ps }),
this.prisma.aiModel.count(),
]);
return { items, total, page: p, pageSize: ps };
}
@Get('models/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async adminModel(@Param('id', ParseIntPipe) id: number) {
const item = await this.prisma.aiModel.findUnique({ where: { id } });
if (!item) throw new HttpException('模型不存在', HttpStatus.NOT_FOUND);
return item;
}
@Post('models')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async createModel(@Body() body: any) {
const item = await this.prisma.aiModel.create({ data: body });
return item;
}
@Put('models/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateModel(@Param('id', ParseIntPipe) id: number, @Body() body: any) {
const { id: _id, ...data } = body;
const item = await this.prisma.aiModel.update({ where: { id }, data });
return item;
}
@Delete('models/:id')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async deleteModel(@Param('id', ParseIntPipe) id: number) {
await this.prisma.aiModel.delete({ where: { id } });
return { ok: true };
}
}
@@ -7,6 +7,17 @@ import { PrismaService } from '../../prisma/prisma.service';
export class PublicController {
constructor(private prisma: PrismaService) {}
@Get('stats')
async getStats() {
const [courses, prompts, tools, users] = await Promise.all([
this.prisma.course.count({ where: { status: 'PUBLISHED', deletedAt: null } }),
this.prisma.prompt.count({ where: { status: 'PUBLISHED' } }),
this.prisma.tool.count({ where: { status: 'PUBLISHED' } }),
this.prisma.user.count({ where: { status: 'ACTIVE' } }),
]);
return { courses, prompts, tools, users };
}
@Get('banners')
async banners(@Query('position') position?: string) {
const now = new Date();
+41 -7
View File
@@ -1,6 +1,7 @@
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
import { Controller, Post, Get, Body, UseGuards, Req, Res } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@@ -10,19 +11,39 @@ import { LoginDto } from './dto/login.dto';
export class AuthController {
constructor(private authService: AuthService) {}
private setTokenCookie(res: any, accessToken: string) {
const isProd = process.env.NODE_ENV === 'production';
res.cookie('token', accessToken, {
httpOnly: true,
secure: isProd,
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
@Post('register')
async register(@Body() body: RegisterDto) {
return this.authService.register(body);
@Throttle({ default: { limit: 3, ttl: 60000 } })
async register(@Body() body: RegisterDto, @Res({ passthrough: true }) res: any) {
const tokens = await this.authService.register(body);
this.setTokenCookie(res, tokens.accessToken);
return tokens;
}
@Post('login')
async login(@Body() body: LoginDto) {
return this.authService.login(body.account, body.password);
@Throttle({ default: { limit: 5, ttl: 60000 } })
async login(@Body() body: LoginDto, @Res({ passthrough: true }) res: any) {
const tokens = await this.authService.login(body.account, body.password);
this.setTokenCookie(res, tokens.accessToken);
return tokens;
}
@Post('refresh')
async refresh(@Body() body: { accessToken: string }) {
return this.authService.refreshAccessToken(body.accessToken);
@Throttle({ default: { limit: 10, ttl: 60000 } })
async refresh(@Body() body: { accessToken: string }, @Res({ passthrough: true }) res: any) {
const tokens = await this.authService.refreshAccessToken(body.accessToken);
this.setTokenCookie(res, tokens.accessToken);
return tokens;
}
@Get('profile')
@@ -31,4 +52,17 @@ export class AuthController {
async profile(@Req() req: any) {
return this.authService.getProfile(req.user.userId);
}
@Get('verify')
@UseGuards(AuthGuard('jwt'))
async verify(@Req() req: any) {
const profile = await this.authService.getProfile(req.user.userId);
return { ...profile, accessToken: req.headers.authorization?.replace('Bearer ', '') || '' };
}
@Post('logout')
async logout(@Res({ passthrough: true }) res: any) {
res.cookie('token', '', { httpOnly: true, maxAge: 0, path: '/' });
return { message: '已退出' };
}
}
+8 -2
View File
@@ -10,9 +10,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private config: ConfigService,
private prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET') || 'yuzhiran-ai-default-secret';
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET environment variable is required');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req) => req?.cookies?.token || null,
]),
ignoreExpiration: false,
secretOrKey: secret,
});
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('分类')
@@ -21,16 +22,22 @@ export class CategoriesController {
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: { name: string; slug: string; description?: string; sortOrder?: number }) {
return this.prisma.category.create({ data: body });
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) {
return this.prisma.category.update({ where: { id: +id }, data: body });
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) {
return this.prisma.category.delete({ where: { id: +id } });
}
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { ContentsService } from './contents.service';
@ApiTags('内容')
@@ -18,16 +19,22 @@ export class ContentsController {
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: any) {
return this.contentsService.create(body);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) {
return this.contentsService.update(+id, body);
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) {
return this.contentsService.remove(+id);
}
@@ -19,6 +19,8 @@ export class CoursesController {
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: {
title: string; description?: string; cover?: string; categoryId?: number;
price?: number; isFree?: boolean;
@@ -27,11 +29,15 @@ export class CoursesController {
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: any) {
return this.coursesService.update(+id, body);
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async remove(@Param('id') id: string) {
return this.coursesService.remove(+id);
}
@@ -9,9 +9,13 @@ class CreateOrderDto {
amount: number;
@IsString()
@IsIn(['MONTHLY', 'YEARLY', 'COURSE'])
@IsIn(['MONTHLY', 'YEARLY', 'COURSE', 'PACKAGE', 'SKILL'])
planType: string;
@IsOptional()
@IsString()
skillId?: string;
@IsOptional()
@IsString()
payChannel?: string;
+22 -2
View File
@@ -9,10 +9,12 @@ export class OrdersService {
private gatewayPay: GatewayPayService,
) {}
async create(userId: number, data: { amount: number; planType: string; payChannel?: string; tradeType?: string; openid?: string }) {
async create(userId: number, data: { amount: number; planType: string; skillId?: string; payChannel?: string; tradeType?: string; openid?: string }) {
const channel = data.payChannel || 'alipay';
const orderNo = `YZR${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const metadata = data.skillId ? JSON.stringify({ skillId: data.skillId }) : null;
const order = await this.prisma.order.create({
data: {
orderNo,
@@ -20,12 +22,15 @@ export class OrdersService {
amount: data.amount,
planType: data.planType,
payChannel: channel,
metadata,
},
});
const planLabels: Record<string, string> = {
MONTHLY: '宇之然AI月卡会员',
YEARLY: '宇之然AI年卡会员',
PACKAGE: '宇之然AI用量包',
SKILL: 'AI技能购买',
};
const subject = planLabels[data.planType] || '宇之然AI会员充值';
@@ -73,7 +78,13 @@ export class OrdersService {
data: { status: 'PAID', paidAt: new Date() },
});
if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
if (order.planType === 'PACKAGE') {
const extraQuota = order.amount >= 49 ? 300 : 50;
await this.prisma.user.update({
where: { id: order.userId },
data: { sandboxExtra: { increment: extraQuota } },
});
} else if (order.planType === 'MONTHLY' || order.planType === 'YEARLY') {
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
let subscriptionEndDate: Date;
@@ -106,6 +117,15 @@ export class OrdersService {
where: { id: order.userId },
data: { memberPlan: order.planType as any, memberExpire: subscriptionEndDate },
});
} else if (order.planType === 'SKILL' && order.metadata) {
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 },
});
}
}
}
@@ -323,7 +323,22 @@ export class GatewayPayService {
where: { id: orderId },
include: { user: true },
});
if (!order || (order.planType !== 'MONTHLY' && order.planType !== 'YEARLY')) return;
if (!order) return;
if (order.planType === 'SKILL' && order.metadata) {
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}`);
}
return;
}
if (order.planType !== 'MONTHLY' && order.planType !== 'YEARLY') return;
const durationDays = order.planType === 'MONTHLY' ? 30 : 365;
const now = new Date();
@@ -1,5 +1,6 @@
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger';
import { Controller, Post, Get, Body, Req, Headers, HttpCode, Query, HttpException, HttpStatus, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { IsString, IsNumber, IsOptional, IsIn } from 'class-validator';
import { GatewayPayService } from './gateway-pay.service';
import { PrismaService } from '../../prisma/prisma.service';
@@ -70,6 +71,8 @@ export class PaymentController {
}
@Post('wxpay/refund')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '微信支付退款(转网关)' })
@ApiBody({ type: RefundDto })
async refund(@Body() body: RefundDto) {
@@ -113,6 +116,8 @@ export class PaymentController {
}
@Post('gateway/sync/:orderNo')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '手动同步订单状态(从网关查询并更新本地)' })
async syncOrderStatus(@Param('orderNo') orderNo: string) {
const result = await this.gatewayPay.syncOrderStatus(orderNo);
@@ -123,6 +128,8 @@ export class PaymentController {
}
@Post('gateway/close/:gatewayOrderId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@ApiOperation({ summary: '关闭未支付订单' })
async closeOrder(@Param('gatewayOrderId') gatewayOrderId: string) {
const ok = await this.gatewayPay.closeOrder(gatewayOrderId);
@@ -0,0 +1,30 @@
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
export class SubmitPracticeDto {
@IsString()
@IsNotEmpty()
answer: string;
@IsOptional()
@IsNumber()
@Min(0)
duration?: number;
}
export class PracticeQueryDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
difficulty?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsString()
skillId?: string;
}
@@ -0,0 +1,60 @@
import { Controller, Get, Post, Param, Query, Body, Req, UseGuards, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { PracticesService } from './practices.service';
import { SubmitPracticeDto, PracticeQueryDto } from './dto/submit-practice.dto';
@ApiTags('练习')
@Controller('practices')
export class PracticesController {
constructor(private practicesService: PracticesService) {}
@Get()
findAll(@Query() query: PracticeQueryDto) {
return this.practicesService.findAll(query);
}
@Get('categories')
getCategories() {
return this.practicesService.getCategories();
}
@Get('difficulties')
getDifficulties() {
return this.practicesService.getDifficulties();
}
@Get('submissions')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
getUserSubmissions(
@Req() req: any,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.practicesService.getUserSubmissions(req.user.userId, page, limit);
}
@Get('submissions/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
getSubmissionDetail(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
return this.practicesService.getSubmissionDetail(req.user.userId, id);
}
@Get(':id')
findById(@Param('id', ParseIntPipe) id: number) {
return this.practicesService.findById(id);
}
@Post(':id/submit')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
submitAnswer(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: SubmitPracticeDto,
) {
return this.practicesService.submitAnswer(req.user.userId, id, body.answer, body.duration || 0);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PracticesController } from './practices.controller';
import { PracticesService } from './practices.service';
import { AIModule } from '../ai/ai.module';
@Module({
imports: [AIModule],
controllers: [PracticesController],
providers: [PracticesService],
exports: [PracticesService],
})
export class PracticesModule {}
@@ -0,0 +1,205 @@
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService } from '../ai/ai-gateway.service';
@Injectable()
export class PracticesService {
private readonly logger = new Logger(PracticesService.name);
constructor(
private prisma: PrismaService,
private ai: AIGatewayService,
) {}
async findAll(query: { category?: string; difficulty?: string; search?: string; skillId?: string }) {
const where: any = { status: 'PUBLISHED' };
if (query.category) where.category = query.category;
if (query.difficulty) where.difficulty = query.difficulty;
if (query.skillId) where.skillId = query.skillId;
if (query.search) {
where.OR = [
{ title: { contains: query.search } },
{ description: { contains: query.search } },
];
}
const items = await this.prisma.practiceQuestion.findMany({
where,
orderBy: { sortOrder: 'asc' },
select: {
id: true,
scenario: true,
title: true,
description: true,
difficulty: true,
category: true,
sortOrder: true,
attemptCount: true,
createdAt: true,
},
});
return { items, total: items.length };
}
async findById(id: number) {
const question = await this.prisma.practiceQuestion.findUnique({
where: { id },
});
if (!question) throw new NotFoundException('练习题目不存在');
return question;
}
async getCategories() {
const result = await this.prisma.practiceQuestion.findMany({
where: { status: 'PUBLISHED' },
select: { category: true },
distinct: ['category'],
});
return result.map(r => r.category);
}
async getDifficulties() {
const result = await this.prisma.practiceQuestion.findMany({
where: { status: 'PUBLISHED' },
select: { difficulty: true },
distinct: ['difficulty'],
});
return result.map(r => r.difficulty);
}
async submitAnswer(userId: number, questionId: number, answer: string, duration: number = 0) {
const question = await this.prisma.practiceQuestion.findUnique({
where: { id: questionId },
});
if (!question) throw new NotFoundException('练习题目不存在');
const existing = await this.prisma.practiceSubmission.findFirst({
where: { userId, questionId, status: 'SUBMITTED' },
});
if (existing) throw new ForbiddenException('你已经提交过此练习的答案');
const submission = await this.prisma.practiceSubmission.create({
data: {
userId,
questionId,
answer,
duration,
status: 'SUBMITTED',
},
});
await this.prisma.practiceQuestion.update({
where: { id: questionId },
data: { attemptCount: { increment: 1 } },
});
try {
const criteria = question.expectedCriteria;
const scoringPrompt = this.buildScoringPrompt(question, answer, criteria);
const aiResult = await this.ai.chat('deepseek-v4-flash', [
{ role: 'system', content: '你是一个专业的 AI 练习评分助手。请严格按照评分标准评估用户的回答,返回 JSON 格式的评分结果。' },
{ role: 'user', content: scoringPrompt },
], { temperature: 0.3 });
const scoreData = this.parseScoreResult(aiResult);
const updated = await this.prisma.practiceSubmission.update({
where: { id: submission.id },
data: {
score: scoreData.totalScore,
maxScore: 100,
feedback: scoreData.feedback,
criteriaScores: JSON.stringify(scoreData.criteria),
status: 'SCORED',
scoredAt: new Date(),
},
});
return updated;
} catch (err: any) {
this.logger.error(`评分失败: ${err.message}`);
return submission;
}
}
async getUserSubmissions(userId: number, page: number = 1, limit: number = 20) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.prisma.practiceSubmission.findMany({
where: { userId },
skip,
take: limit,
orderBy: { submittedAt: 'desc' },
include: {
question: {
select: { id: true, title: true, scenario: true, difficulty: true, category: true },
},
},
}),
this.prisma.practiceSubmission.count({ where: { userId } }),
]);
return { items, total, page, limit };
}
async getSubmissionDetail(userId: number, submissionId: number) {
const submission = await this.prisma.practiceSubmission.findUnique({
where: { id: submissionId },
include: { question: true },
});
if (!submission) throw new NotFoundException('提交记录不存在');
if (submission.userId !== userId) throw new ForbiddenException('无权查看此提交');
return submission;
}
private buildScoringPrompt(question: any, answer: string, criteria: string): string {
return `请根据以下场景和评分标准,评估用户的回答。
## 练习场景
${question.scenario}
## 练习要求
${question.instructions}
## 用户的回答
${answer}
## 评分标准
${criteria}
## 输出格式(必须返回纯 JSON,不要包含 markdown 代码块)
{
"criteria": [
{ "name": "评分项名称", "score": 分数, "maxScore": 满分, "reason": "评分理由" }
],
"totalScore": 总分,
"feedback": "总体反馈和改进建议(50-200字)"
}`;
}
private parseScoreResult(aiResult: string): { criteria: any[]; totalScore: number; feedback: string } {
try {
const cleaned = aiResult.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const parsed = JSON.parse(cleaned);
return {
criteria: parsed.criteria || [],
totalScore: Math.min(100, Math.max(0, parsed.totalScore || 0)),
feedback: parsed.feedback || '评分完成',
};
} catch {
this.logger.warn(`AI 评分结果解析失败,使用默认评分: ${aiResult.slice(0, 100)}`);
return {
criteria: [{ name: '综合评分', score: 75, maxScore: 100, reason: 'AI 评分解析失败,已使用默认分数' }],
totalScore: 75,
feedback: 'AI 评分系统遇到临时问题,请稍后重新查看评分详情。',
};
}
}
}
+33 -7
View File
@@ -26,7 +26,7 @@ export class SandboxService {
const convId = conversationId || randomUUID();
// 今日配额:按 conversationId 去重计数
// 配额检查:先用每日免费配额,再用购买的额外配额
const today = new Date();
today.setHours(0, 0, 0, 0);
const existing = await this.prisma.sandboxSession.findUnique({
@@ -36,8 +36,16 @@ export class SandboxService {
const todayCount = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
if (todayCount >= (user.sandboxDaily || 10)) {
throw new HttpException('今日沙箱使用次数已用完', HttpStatus.TOO_MANY_REQUESTS);
const dailyLimit = user.sandboxDaily || 10;
if (todayCount >= dailyLimit) {
if ((user.sandboxExtra || 0) > 0) {
await this.prisma.user.update({
where: { id: userId },
data: { sandboxExtra: { decrement: 1 } },
});
} else {
throw new HttpException('今日沙箱使用次数已用完', HttpStatus.TOO_MANY_REQUESTS);
}
}
}
@@ -104,9 +112,17 @@ export class SandboxService {
const todayCount = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
if (todayCount >= (user.sandboxDaily || 10)) {
yield JSON.stringify({ type: 'error', message: '今日沙箱使用次数已用完' } as StreamResult);
return;
const dailyLimit = user.sandboxDaily || 10;
if (todayCount >= dailyLimit) {
if ((user.sandboxExtra || 0) > 0) {
await this.prisma.user.update({
where: { id: userId },
data: { sandboxExtra: { decrement: 1 } },
});
} else {
yield JSON.stringify({ type: 'error', message: '今日沙箱使用次数已用完' } as StreamResult);
return;
}
}
}
@@ -259,7 +275,17 @@ export class SandboxService {
const used = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
return { dailyLimit: user?.sandboxDaily || 10, used, remaining: (user?.sandboxDaily || 10) - used };
const dailyLimit = user?.sandboxDaily || 10;
const extra = user?.sandboxExtra || 0;
const dailyRemaining = Math.max(0, dailyLimit - used);
return {
dailyLimit,
used,
dailyRemaining,
extra,
canUse: dailyRemaining > 0 || extra > 0,
totalRemaining: dailyRemaining + extra,
};
}
async generateShareToken(userId: number, sessionId: number) {
@@ -129,7 +129,7 @@ describe('SandboxService', () => {
describe('getQuota', () => {
it('should return quota info', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: 1, sandboxDaily: 10
id: 1, sandboxDaily: 10, sandboxExtra: 0
});
mockPrisma.sandboxSession.count.mockResolvedValue(3);
@@ -138,7 +138,10 @@ describe('SandboxService', () => {
expect(result).toEqual({
dailyLimit: 10,
used: 3,
remaining: 7,
dailyRemaining: 7,
extra: 0,
canUse: true,
totalRemaining: 7,
});
});
});
@@ -1,5 +1,6 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { SkillsService } from './skills.service';
@ApiTags('技能')
@@ -12,6 +13,12 @@ export class SkillsController {
return this.skillsService.findAll(query);
}
@Get('marketplace')
getMarketplace(@Req() req: any) {
const userId = req.user?.userId;
return this.skillsService.getMarketplace(userId);
}
@Get('categories')
getCategories() {
return this.skillsService.getCategories();
@@ -23,8 +30,9 @@ export class SkillsController {
}
@Get(':id')
findById(@Param('id') id: string) {
const skill = this.skillsService.findById(id);
async findById(@Req() req: any, @Param('id') id: string) {
const userId = req.user?.userId;
const skill = await this.skillsService.findById(id, userId);
if (!skill) return { error: '技能不存在' };
return skill;
}
+42 -2
View File
@@ -45,17 +45,57 @@ export class SkillsService {
};
}
async findById(id: string) {
async findById(id: string, userId?: number) {
const skill = await this.prisma.skill.findUnique({ where: { id } });
if (!skill) return null;
let purchased = false;
if (userId && skill.price) {
const record = await this.prisma.userSkill.findUnique({
where: { userId_skillId: { userId, skillId: id } },
});
purchased = !!record;
}
return {
...skill,
starters: JSON.parse(skill.starters),
tasks: JSON.parse(skill.tasks),
tags: this.parseTags(skill.tags),
purchased,
locked: !purchased && !!skill.price,
};
}
async getMarketplace(userId?: number) {
const skills = await this.prisma.skill.findMany({
where: { status: 'ACTIVE' },
orderBy: { sortOrder: 'asc' },
});
let purchasedSet = new Set<string>();
if (userId) {
const records = await this.prisma.userSkill.findMany({
where: { userId },
select: { skillId: true },
});
purchasedSet = new Set(records.map(r => r.skillId));
}
return skills.map(s => ({
id: s.id,
name: s.name,
description: s.description,
icon: s.icon,
category: s.category,
difficulty: s.difficulty,
tags: this.parseTags(s.tags),
price: s.price,
sortOrder: s.sortOrder,
purchased: purchasedSet.has(s.id),
}));
}
async getCategories() {
const cats = await this.prisma.skill.findMany({
where: { status: 'ACTIVE' },
@@ -63,7 +103,7 @@ export class SkillsService {
distinct: ['category'],
orderBy: { sortOrder: 'asc' },
});
const labels: Record<string, string> = { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业' };
const labels: Record<string, string> = { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业', marketing: '营销', business: '商业' };
return cats.map(c => ({ id: c.category, name: labels[c.category] || c.category }));
}
@@ -16,7 +16,7 @@ export class ToolsController {
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
async create(@Body() body: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) {
return this.toolsService.create(body);
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export class ToolsService {
return { items, total, page, pageSize };
}
async create(data: { name: string; description?: string; url: string; icon?: string; categoryId?: number; tags?: string }) {
async create(data: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) {
return this.prisma.tool.create({ data });
}
}
@@ -4,12 +4,14 @@ import {
UseInterceptors,
UploadedFile,
BadRequestException,
UseGuards,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { randomUUID } from 'crypto';
import { ApiTags } from '@nestjs/swagger';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const MAX_SIZE = 5 * 1024 * 1024;
@@ -18,6 +20,8 @@ const MAX_SIZE = 5 * 1024 * 1024;
@Controller('upload')
export class UploadController {
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
+232
View File
@@ -0,0 +1,232 @@
# 项目进度追踪
## 当前阶段:P4 — 支付闭环 + 部署
### Phase 1-4 — 全部完成 ✅
### P0 — 全局优化(全部完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **P0a** | i18n 基础设施 + 翻译文件 + LanguageProvider + useT hook | ✅ |
| **P0b** | UI 统一 — Design Token + 共享组件库 | ✅ |
| **P0c** | 逐页翻译 + 语言切换 — 沙盒页面 useT 化 | ✅ |
| **P0d** | 会员定价页重构(用量可视化) | ✅ |
| **P0e** | 模型选择器升级(卡片式 + 能力标签) | ✅ |
| **P0f** | 会话分享链接 — HMAC 签名 + 分享查看页 | ✅ |
### P1 — 学习技能包(全部完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **后端** | SkillsModule + 8 技能定义 + 过滤/详情 API | ✅ |
| **前端** | /skills 市场 + /skills/[id] 详情 + header 入口 | ✅ |
| **沙盒集成** | 替代硬编码 SCENES,从 API 动态加载,?skill=xxx 支持 | ✅ |
### P2 — CSS 统一(全部完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **Mobile variables.css** | Design Token 变量定义(Web/Mobile 统一) | ✅ |
| **Mobile 全站 18 页** | 批量替换 hardcoded color → CSS vars | ✅ |
| **PC 响应式** | header/compare/sandbox/member/workshop 移动端修复 | ✅ |
### P3 — 体验增强(全部完成 ✅)
| 优先级 | 项目 | 说明 | 状态 |
|--------|------|------|------|
| **P4a** | 多模态 — 图片上传 + 沙盒 Vision | ✅ 完成 |
| **P4b** | 代码语法高亮 | highlight.js + CodeBlock 组件 | ✅ |
| **P3c** | 会话重命名 | PATCH endpoint + 双击编辑 | ✅ |
| — | SDK / 共享 UI 包 | 远期规划 | 📋 |
### P4 — 支付闭环 + 统一网关(已完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **P4a** | 统一支付网关集成 — `GatewayPayService` HMAC-SHA256 对接 yzrcloud.cn 网关,支持支付宝/微信 | ✅ |
| **P4b** | 会员页双支付按钮 — 支付宝/微信可选,统一走网关 | ✅ |
| **P4c** | 网关 Webhook 接收 — `POST /api/v1/payment/gateway/webhook`,处理 `recharge.completed` / `order.refunded` | ✅ |
| **P4d** | 管理后台订单增强 — 搜索/分页/渠道筛选/退款弹窗/标记已付 | ✅ |
| **P4e** | Mock 降级 — 网关不可达时自动 mock 完成支付,不影响开发 | ✅ |
| — | 订阅到期提醒 / 自动续费 | 📋 远期 |
### P5 — 运营助手(已完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **P5a** | 新增 `POST /admin/ai-assistant/chat` 端点 — 管理员专用 AI 聊天 | ✅ |
| **P5b** | `AdminAiAssistantService` — 19 个可执行工具定义 | ✅ |
| **P5c** | `POST /admin/ai-assistant/action` — 工具执行 + AI 总结 | ✅ |
| **P5d** | 前端 Tool Calling 架构 — AI 自动识别意图→调用→解释→展示 | ✅ |
| **P5e** | system prompt 动态注入工具列表 | ✅ |
---
## 部署架构
### 域名概况
| 域名 | 类型 | 说明 | 状态 |
|------|------|------|------|
| **www.yuzhiran.com** | AI 学习平台 | 前端静态导出 + NestJS 后端 | 🟢 线上 |
| **trade.yuzhiran.com** | 另一项目(非 AI 平台) | SPA 应用,独立后端 8002 端口 | 🟢 线上 |
| **alvnc.yuzhiran.com** | 其他服务 | — | 🟢 线上 |
### www.yuzhiran.com 部署详情
```
用户 → Nginx (443/80) → /api/* → backend (127.0.0.1:4000)
→ /* → static files (/www/wwwroot/www.yuzhiran.com/)
```
| 组件 | 技术栈 | 运行方式 | 端口 |
|------|--------|---------|------|
| **前端** | Next.js 14 App Router → `output: 'export'` 静态 HTML | Nginx 直接提供 `/www/wwwroot/www.yuzhiran.com/` | 443/80 |
| **后端** | NestJS + Prisma + MySQL | PM2 (`yuzhiran-api`) | 4000 |
| **数据库** | MySQL 8.0 | systemd (`ai_leaning`) | 3306 |
| **Web 服务器** | Nginx (宝塔面板) | systemd | 443/80 |
| **支付网关** | yzrcloud.cn 统一网关 | 外部服务 | 8100(内网) |
**Nginx 配置** (`/www/server/panel/vhost/nginx/www.yuzhiran.com.conf`):
- 静态资源 `/``try_files $uri $uri.html $uri/index.html =404`
- API 代理 `/api/``proxy_pass http://127.0.0.1:4000`
- 静态资源缓存: `_next/static/` 365 天, 图片 30 天, JS/CSS 12 小时
**部署流程**:
```bash
# 前端
cd frontend && npm run build # 生成 out/
cp -r out/* /www/wwwroot/www.yuzhiran.com/
# 后端
cd backend && npm run build # 生成 dist/
pm2 restart yuzhiran-api
```
### trade.yuzhiran.com(独立项目)
非 AI 学习平台项目,独立部署:
- 前端 SPA (React)root at `/www/wwwroot/trade.yuzhiran.com/`
- 后端 API at `127.0.0.1:8002`
- 路由: `/admin/` → SPA fallback, `/workspace/` → SPA fallback
---
## 支付对接
### 架构总览
```
前端 (会员页) → POST /orders/create → GatewayPayService → HMAC-SHA256 → yzrcloud.cn 网关
├── 支付宝 PC 网站
└── 微信 Native 扫码
网关回调 → POST /api/v1/payment/gateway/webhook → 更新订单 + 激活订阅
```
### 统一支付网关对接参数
| 参数 | 值 | 来源 |
|------|-----|------|
| 网关地址(内网) | `http://localhost:8100` | `.env` |
| 网关地址(外网) | `https://www.yzrcloud.cn/api/gateway` | 未启用 |
| API Key | `pay_2ac89d9831244c2ebdbb2a24` | AI学习平台.key |
| API Secret | `6a2e6c6801384bd0800849ef42ad5e8ef51898819895478a` | AI学习平台.key |
| 签名算法 | HMAC-SHA256 | `sign_str = "{method}\n{path}\n{timestamp}\n{body_sha256}"` |
| Webhook | `POST /api/v1/payment/gateway/webhook` | 后端接收 |
| 支付方式 | `alipay` / `wechat` | 网关统一处理 |
| Mock 模式 | `GATEWAY_PAY_MOCK=true` | 开发环境启用 |
### 后端对接文件
| 文件 | 说明 |
|------|------|
| `backend/src/modules/payment/gateway-pay.service.ts` | 核心网关服务,HMAC 签名 + 下单/查询/退款/Webhook |
| `backend/src/modules/payment/payment.controller.ts` | `/api/v1/payment/gateway/webhook` 等端点 |
| `backend/src/modules/orders/orders.service.ts` | 下单路由,统一走网关 |
| `backend/.env` | `GATEWAY_PAY_*` 配置项 |
### API 端点一览
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| POST | `/api/v1/payment/gateway/webhook` | 网关支付成功/退款回调 | 无(网关签名在 body 中) |
| GET | `/api/v1/payment/wxpay/query?outTradeNo=xxx` | 订单状态查询(转网关) | JWT |
| POST | `/api/v1/payment/wxpay/refund` | 退款(转网关) | JWT |
| POST | `/api/v1/orders/create` | 创建订单+发起支付 | JWT |
| GET | `/api/v1/admin/orders?page=&search=&status=&payChannel=` | 管理员订单搜索 | Admin JWT |
| POST | `/api/v1/admin/orders/:orderNo/refund` | 管理员退款 | Admin JWT |
| POST | `/api/v1/admin/orders/:orderNo/mark-paid` | 标记人工已付 | Admin JWT |
### 商户密钥文件
备份位置: `/root/hermes-workspace/backup/网关支付/pay-api/merchants/AI学习平台.key`
---
## 2026-05-31 Session (下午)
### Changes Made
- **AI 助手架构升级**: 用户端 + 管理后台双 AI 助手均采用完整 Tool Calling 架构
- **新增 `UserAiAssistantService`**: 后台工具执行服务,18 个工具(搜索/技能/学情/通知/个人信息/模型/提示词/课程/订单)
- **新增 `AiAssistantController`**: `POST /api/v1/ai-assistant/chat` + `/action` 端点
- **新增 `AiAssistantModule`**: 注册 SkillsModule + AIModule 依赖
- **重写前端 `ai-assistant.tsx`**: Tool Calling 架构 + 页面感知上下文 + 登录控制
- **更新前端 `admin-ai-assistant.tsx`**: 新增 search/mark-all-notifications-read 工具描述 + 更多 starter
- **新增后台 `search`/`mark-all-notifications-read` 工具**: 全局搜索用户/订单/课程/内容 + 通知批量已读
- **重构 `assistant-actions.ts`**: 统一 `executeClientAction()` API,支持所有客户端操作
- **清理 `assistant-context.ts`**: 精简为纯上下文数据源,移除旧 action 格式
### 用户端 AI 助手可用操作
| 分类 | 工具 |
|------|------|
| 🧭 导航 | `navigate` — 跳转到任意页面 |
| 🔍 搜索 | `search` — 全局搜索课程/提示词/工具/文章 |
| 🎯 技能 | `list-skills` / `get-skill` / `get-skill-categories` |
| 📊 学情 | `get-learning-analytics` / `get-learning-path` |
| 🔔 通知 | `list-notifications` / `get-unread-count` / `mark-notification-read` / `mark-all-read` |
| 👤 个人 | `get-profile` / `list-user-orders` / `get-current-subscription` |
| 📚 内容 | `list-models` / `list-prompts` / `list-courses` |
| ⚡ 客户端 | `setModel` / `startChat` / `openSkill` / `setParameter` |
### 管理后台新增工具
| 工具 | 说明 |
|------|------|
| `search` | 全局搜索用户/订单/课程/内容 |
| `mark-all-notifications-read` | 批量标记所有通知已读 |
### Verification
- ✅ 后端 92 项测试全部通过
- ✅ 后端 TypeScript 编译无错误
- ✅ 前端 83 静态页面全部生成成功(含 8 个 /practices/[id] 页面)
---
## 2026-06-10 Session (续) — ¥9.9 用量包 + 企业版 MVP
### ¥9.9 用量包系统
- **User 模型**: 新增 `sandboxExtra Int @default(0)` 字段(购买的额外配额)
- **配额逻辑改造** (`SandboxService`): 优先消耗每日免费 → 不足时扣除 `sandboxExtra``getQuota()` 返回 `dailyRemaining` + `extra` + `totalRemaining`
- **Orders 支持 `PACKAGE`**: `completeMockPayment()` 检测 `PACKAGE` 时按金额增加 extra 配额(¥9.9→50次, ¥49→300次)
- **前端沙盒**: 配额显示双层(今日已用 + 剩余额外),超限跳转购买页
- **新增 `/practices/packages`**: 三档用量包购买页(¥4.9/10次, ¥9.9/50次, ¥49/300次),复用现有支付流程
### 企业版 MVP
- **后端**: 11 enterprise API 端点 + Organization/OrganizationMember 模型 + 完整 CRUD(已有)
- **新增 `/enterprise`**: 营销着陆页 — Shadow AI 数据隔离故事、六大功能、定价、FAQ
- **新增 `/enterprise/dashboard`**: 团队管理后台 — 创建组织/邀请成员/使用报告
- **Header 导航**: 新增「企业版」入口
- **i18n**: 完整 `enterprise.*` 翻译 keys
### Files Created
| 文件 | 说明 |
|------|------|
| `frontend/src/app/practices/packages/page.tsx` | 用量包购买页 |
| `frontend/src/app/enterprise/page.tsx` | 企业版营销着陆页 |
| `frontend/src/app/enterprise/dashboard/page.tsx` | 团队管理后台 |
### Verification
- ✅ 后端 92 项测试全部通过
- ✅ Prisma migrate 成功
- ✅ 前端 86 个静态页面全部生成成功
- ✅ Header 导航已更新(练习 + 企业版)
- ✅ i18n 中英文完整翻译
### Verification
- ✅ 后端 92 项测试全部通过
- ✅ 后端 TypeScript 编译无错误
- ✅ 前端 75 静态页面全部生成成功
+101
View File
@@ -0,0 +1,101 @@
# 项目进度追踪
## 当前阶段:P6 — 技能广场 + 付费技能
### Phase 1-5 — 全部完成 ✅
### P6 — 技能广场 & 付费技能(2026-06-14
| 子项 | 说明 | 状态 |
|------|------|------|
| **市场调研** | 选定方向:付费 AI Skill(每个 Skill 解决一个垂直痛点) | ✅ |
| **Prisma Schema** | Skill 模型加 `price` 字段;新增 `UserSkill` 关联表;Order 加 `metadata` | ✅ |
| **后端** | Orders 支持 `planType: 'SKILL'`Skills 端增加 access control + marketplace API | ✅ |
| **第一个 Skill** | 标题大师 (TitleCraft) — AI 爆款标题生成器,¥9.9 | ✅ |
| **第二批 5 个 Skill** | 回款助手(¥19.9)、提案智造(¥19.9)、差评分析师(¥19.9)、商品文案大师(¥29.9)、合同审查助手(¥29.9) | ✅ |
| **前端** | `/marketplace` 技能广场页:过滤(全部/免费/付费/已拥有)、购买按钮、支付弹窗 | ✅ |
| **导航** | Header 新增「技能广场」入口 | ✅ |
| **i18n** | 中英文 marketplace.* 完整翻译 key + business 分类 | ✅ |
### 已做关键决策
- Skill = 最小可交付产品单元。不是"又一个 AI 平台",而是**每做一个 Skill 就单独卖**
- 第一个 Skill 选"标题大师"——最窄、最痛、最快见效,不依赖人脉
- 定价 ¥9.9(低门槛试探市场),后续 Skill 可根据价值定价 ¥19.9-¥49
- 购买后永久拥有,不走订阅制(降低决策门槛)
### 部署备忘
```bash
# 前端部署后需要:
cd backend && node prisma/update-skill.cjs # 如果 system prompt 需要更新
```
### 待办/下一步
1. **写第一篇知乎文章**:《我做了个 AI 付费技能,¥9.9 能卖出去吗?》—— 用内容验证需求
2. **如果 2 周内无人购买**:换第二个 Skill(如"差评回复生成器"或"小红书文案仿写"
3. **如果有人购买**:深入优化标题大师,考虑涨价到 ¥19.9,同时做第二个 Skill
4. **长期**:每个 Skill 都是一个独立获客入口 + 内容素材
---
## 部署架构
### 域名概况
| 域名 | 类型 | 说明 | 状态 |
|------|------|------|------|
| **www.yuzhiran.com** | AI 学习平台 | 前端静态导出 + NestJS 后端 | 🟢 线上 |
| **trade.yuzhiran.com** | 另一项目(非 AI 平台) | SPA 应用,独立后端 8002 端口 | 🟢 线上 |
| **alvnc.yuzhiran.com** | 其他服务 | — | 🟢 线上 |
### www.yuzhiran.com 部署详情
```
用户 → Nginx (443/80) → /api/* → backend (127.0.0.1:4000)
→ /* → static files (/www/wwwroot/www.yuzhiran.com/)
```
| 组件 | 技术栈 | 运行方式 | 端口 |
|------|--------|---------|------|
| **前端** | Next.js 14 App Router → `output: 'export'` 静态 HTML | Nginx 直接提供 `/www/wwwroot/www.yuzhiran.com/` | 443/80 |
| **后端** | NestJS + Prisma + MySQL | PM2 (`backend`) | 4000 |
| **数据库** | MySQL 8.0 | systemd (`ai_leaning`) | 3306 |
| **Web 服务器** | Nginx (宝塔面板) | systemd | 443/80 |
| **支付网关** | yzrcloud.cn 统一网关 | 外部服务 | 8100(内网) |
**Nginx 配置** (`/www/server/panel/vhost/nginx/www.yuzhiran.com.conf`):
- 静态资源 `/``try_files $uri $uri.html $uri/index.html =404`
- API 代理 `/api/``proxy_pass http://127.0.0.1:4000`
- 静态资源缓存: `_next/static/` 365 天, 图片 30 天, JS/CSS 12 小时
**部署流程**:
```bash
# 前端
cd frontend && npm run build # 生成 out/
cp -r out/* /www/wwwroot/www.yuzhiran.com/
# 后端
cd backend && npm run build # 生成 dist/
pm2 restart backend
```
---
## 支付对接
### 架构总览
```
前端 (会员页) → POST /orders/create → GatewayPayService → HMAC-SHA256 → yzrcloud.cn 网关
├── 支付宝 PC 网站
└── 微信 Native 扫码
网关回调 → POST /api/v1/payment/gateway/webhook → 更新订单 + 激活订阅/关联技能
```
### 技能购买流程
```
用户点击"解锁" → POST /orders/create (planType=SKILL, skillId=xxx)
→ mock支付自动完成
→ UserSkill 记录创建
→ 技能广场页显示"已拥有"
→ 用户可直接在沙盒使用
```
Executable → Regular
+73 -204
View File
@@ -1,59 +1,81 @@
# 项目进度追踪
## 当前阶段:P4支付闭环 + 部署
## 当前阶段:P8平台轻量化改造
### Phase 1-4 — 全部完成
### P6 — 技能广场 & 付费技能(2026-06-14
### P0 — 全局优化(全部完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **P0a** | i18n 基础设施 + 翻译文件 + LanguageProvider + useT hook | ✅ |
| **P0b** | UI 统一 — Design Token + 共享组件库 | ✅ |
| **P0c** | 逐页翻译 + 语言切换 — 沙盒页面 useT 化 | ✅ |
| **P0d** | 会员定价页重构(用量可视化) | ✅ |
| **P0e** | 模型选择器升级(卡片式 + 能力标签) | ✅ |
| **P0f** | 会话分享链接 — HMAC 签名 + 分享查看页 | ✅ |
| **市场调研** | 选定方向:付费 AI Skill(每个 Skill 解决一个垂直痛点) | ✅ |
| **Prisma Schema** | Skill 模型加 `price` 字段;新增 `UserSkill` 关联表;Order 加 `metadata` | ✅ |
| **后端** | Orders 支持 `planType: 'SKILL'`Skills 端增加 access control + marketplace API | ✅ |
| **第一个 Skill** | 标题大师 (TitleCraft) — AI 爆款标题生成器,¥9.9 | ✅ |
| **第二批 5 个 Skill** | 回款助手(¥19.9)、提案智造(¥19.9)、差评分析师(¥19.9)、商品文案大师(¥29.9)、合同审查助手(¥29.9) | ✅ |
| **前端** | `/marketplace` 技能广场页:过滤(全部/免费/付费/已拥有)、购买按钮、支付弹窗 | ✅ |
| **导航** | Header 新增「技能广场」入口 | ✅ |
| **i18n** | 中英文 marketplace.* 完整翻译 key + business 分类 | ✅ |
### P7 — 管理后台 CRUD + 平台审计(2026-06-17
### P1 — 学习技能包(全部完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **后端** | SkillsModule + 8 技能定义 + 过滤/详情 API | ✅ |
| **前端** | /skills 市场 + /skills/[id] 详情 + header 入口 | ✅ |
| **沙盒集成** | 替代硬编码 SCENES,从 API 动态加载,?skill=xxx 支持 | ✅ |
| **Admin CRUD - Practices** | 后端完整 CRUD 端点 + 前端 list/new/edit 页面 | ✅ |
| **Admin CRUD - Posts** | 后端 list/detail/status/delete + 前端 list/detail 页面 | ✅ |
| **Admin CRUD - Models** | 后端完整 CRUD + 前端 list/new/edit 页面 | ✅ |
| **Admin CRUD - Skills** | 后端 list/detail/edit 端点 | ✅ |
| **Admin CRUD - Circles** | 后端 list/delete 端点 | ✅ |
| **Admin 首页** | 新增 practices、posts、models 管理入口 | ✅ |
| **前端数据完整性审计** | 确认所有展示页面均从 API 获取数据,无 mock/静态数据残留 | ✅ |
| **¥{price} 变量修复** | marketplace 页 ¥{price} 缺少 .replace() 问题修复 | ✅ |
| **i18n 模板变量审计** | 审计 19 个模板变量,确认无其它遗漏 | ✅ |
| **Circles JSX 语法修复** | 缺失 `</div>` 导致的编译错误 | ✅ |
| **构建部署** | 108/108 页面,主站 + 镜像站已更新,Nginx reload | ✅ |
| **战略分析** | 平台瘦身可能性 + 广告/返佣模式调研(详见分析报告) | ✅ |
### 已做关键决策
- Skill = 最小可交付产品单元。不是"又一个 AI 平台",而是**每做一个 Skill 就单独卖**
- 第一个 Skill 选"标题大师"——最窄、最痛、最快见效,不依赖人脉
- 定价 ¥9.9(低门槛试探市场),后续 Skill 可根据价值定价 ¥19.9-¥49
- 购买后永久拥有,不走订阅制(降低决策门槛)
- **(P7)** 战略方向思考:是否从"重平台"转向"轻量级信息展示 +练习 +联盟返佣"
### 部署备忘
```bash
# 前端部署后需要:
cd backend && node prisma/update-skill.cjs # 如果 system prompt 需要更新
```
### P8 — 平台轻量化改造(2026-06-17
### P2 — CSS 统一(全部完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **Mobile variables.css** | Design Token 变量定义(Web/Mobile 统一) | ✅ |
| **Mobile 全站 18 页** | 批量替换 hardcoded color → CSS vars | ✅ |
| **PC 响应式** | header/compare/sandbox/member/workshop 移动端修复 | ✅ |
| **Prisma Schema** | Tool 模型加 `affiliateLink` 字段 | ✅ |
| **免费用户配额** | 沙盒配额 10→5 次/日 | ✅ |
| **后端 API** | Tools 返回 affiliateLink;新增 `/admin/tools` CRUD 5 端点 | ✅ |
| **导航调整** | 主菜单隐藏企业版、社区(页面保留 URL 访问) | ✅ |
| **首页重定位** | Hero 文案改为"AI 工具指南 · 实战练习"Feature 卡片重写 | ✅ |
| **精选工具区块** | 首页新增"精选 AI 工具"展示区(Stats 与 Features 之间) | ✅ |
| **工具页返佣 Badge** | 有 affiliateLink 的工具显示绿色"推荐"标记 | ✅ |
| **AGENTS.md 更新** | 定位改为"AI 工具指南与技能练习平台";路线图重排序 | ✅ |
| **构建部署** | 前端 108/108 页面 + 后端 build + PM2 restart + Nginx reload | ✅ |
### P3 — 体验增强(全部完成 ✅
| 优先级 | 项目 | 说明 | 状态 |
|--------|------|------|------|
| **P4a** | 多模态 — 图片上传 + 沙盒 Vision | ✅ 完成 |
| **P4b** | 代码语法高亮 | highlight.js + CodeBlock 组件 | ✅ |
| **P3c** | 会话重命名 | PATCH endpoint + 双击编辑 | ✅ |
| — | SDK / 共享 UI 包 | 远期规划 | 📋 |
### P8.1 — SSG 动态路由修复 + 编程导师场景(2026-06-18
### P4 — 支付闭环 + 统一网关(已完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **P4a** | 统一支付网关集成 — `GatewayPayService` HMAC-SHA256 对接 yzrcloud.cn 网关,支持支付宝/微信 | ✅ |
| **P4b** | 会员页双支付按钮 — 支付宝/微信可选,统一走网关 | ✅ |
| **P4c** | 网关 Webhook 接收 — `POST /api/v1/payment/gateway/webhook`,处理 `recharge.completed` / `order.refunded` | ✅ |
| **P4d** | 管理后台订单增强 — 搜索/分页/渠道筛选/退款弹窗/标记已付 | ✅ |
| **P4e** | Mock 降级 — 网关不可达时自动 mock 完成支付,不影响开发 | ✅ |
| — | 订阅到期提醒 / 自动续费 | 📋 远期 |
| **SSG 404 根因诊断** | `NEXT_PUBLIC_API_URL=/api/v1` 在 Node.js 构建时不解析,所有动态路由只生成了 fallback ID `1` | ✅ |
| **config.ts 修复** | SSG 构建时自动使用 `http://localhost:4000/api/v1` 直连后端 | ✅ |
| **页面数验证** | 108→127 页面,courses/[id] 正确生成 ID 17/18/19skills/[id] 正确生成 general-chat/coding/writing | ✅ |
| **编程导师场景** | 沙盒新增 `sceneId=tutor`,苏格拉底式教学法 prompt4 个 starter 问题 | ✅ |
| **i18n 同步** | 中英文新增 `sceneTutor: '编程导师'/'Programming Tutor'` | ✅ |
| **项目文档更新** | README.md 全面重写(技术栈、产品定位、功能表);AGENTS.md 定位同步 | ✅ |
| **生产部署** | 127 页面全部部署并验证 courses 详情页 200 OK | ✅ |
### P5 — 运营助手(已完成 ✅)
| 子项 | 说明 | 状态 |
|------|------|------|
| **P5a** | 新增 `POST /admin/ai-assistant/chat` 端点 — 管理员专用 AI 聊天 | ✅ |
| **P5b** | `AdminAiAssistantService` — 19 个可执行工具定义 | ✅ |
| **P5c** | `POST /admin/ai-assistant/action` — 工具执行 + AI 总结 | ✅ |
| **P5d** | 前端 Tool Calling 架构 — AI 自动识别意图→调用→解释→展示 | ✅ |
| **P5e** | system prompt 动态注入工具列表 | ✅ |
### 待办/下一步
1. **为工具库加返佣链接**:在管理后台 `/admin/tools/new` 和编辑页录入 affiliateLink 字段
2. **注册 AI 工具联盟账号**:对接 Canva/Copy.ai 等工具的返佣计划
3. **内容策略**:批量生产 AI 工具评测文章做 SEO
4. **编程导师内容扩充**:按难度分级写更多 starter 问题
---
@@ -71,13 +93,13 @@
```
用户 → Nginx (443/80) → /api/* → backend (127.0.0.1:4000)
→ /* → static files (/www/wwwroot/www.yuzhiran.com/)
→ /* → static files (/www/wwwroot/www.yuzhiran.com/)
```
| 组件 | 技术栈 | 运行方式 | 端口 |
|------|--------|---------|------|
| **前端** | Next.js 14 App Router → `output: 'export'` 静态 HTML | Nginx 直接提供 `/www/wwwroot/www.yuzhiran.com/` | 443/80 |
| **后端** | NestJS + Prisma + MySQL | PM2 (`yuzhiran-api`) | 4000 |
| **后端** | NestJS + Prisma + MySQL | PM2 (`backend`) | 4000 |
| **数据库** | MySQL 8.0 | systemd (`ai_leaning`) | 3306 |
| **Web 服务器** | Nginx (宝塔面板) | systemd | 443/80 |
| **支付网关** | yzrcloud.cn 统一网关 | 外部服务 | 8100(内网) |
@@ -95,16 +117,9 @@ cp -r out/* /www/wwwroot/www.yuzhiran.com/
# 后端
cd backend && npm run build # 生成 dist/
pm2 restart yuzhiran-api
pm2 restart backend
```
### trade.yuzhiran.com(独立项目)
非 AI 学习平台项目,独立部署:
- 前端 SPA (React)root at `/www/wwwroot/trade.yuzhiran.com/`
- 后端 API at `127.0.0.1:8002`
- 路由: `/admin/` → SPA fallback, `/workspace/` → SPA fallback
---
## 支付对接
@@ -113,162 +128,16 @@ pm2 restart yuzhiran-api
```
前端 (会员页) → POST /orders/create → GatewayPayService → HMAC-SHA256 → yzrcloud.cn 网关
├── 支付宝 PC 网站
└── 微信 Native 扫码
网关回调 → POST /api/v1/payment/gateway/webhook → 更新订单 + 激活订阅
├── 支付宝 PC 网站
└── 微信 Native 扫码
网关回调 → POST /api/v1/payment/gateway/webhook → 更新订单 + 激活订阅/关联技能
```
### 统一支付网关对接参数
| 参数 | 值 | 来源 |
|------|-----|------|
| 网关地址(内网) | `http://localhost:8100` | `.env` |
| 网关地址(外网) | `https://www.yzrcloud.cn/api/gateway` | 未启用 |
| API Key | `pay_2ac89d9831244c2ebdbb2a24` | AI学习平台.key |
| API Secret | `6a2e6c6801384bd0800849ef42ad5e8ef51898819895478a` | AI学习平台.key |
| 签名算法 | HMAC-SHA256 | `sign_str = "{method}\n{path}\n{timestamp}\n{body_sha256}"` |
| Webhook | `POST /api/v1/payment/gateway/webhook` | 后端接收 |
| 支付方式 | `alipay` / `wechat` | 网关统一处理 |
| Mock 模式 | `GATEWAY_PAY_MOCK=true` | 开发环境启用 |
### 后端对接文件
| 文件 | 说明 |
|------|------|
| `backend/src/modules/payment/gateway-pay.service.ts` | 核心网关服务,HMAC 签名 + 下单/查询/退款/Webhook |
| `backend/src/modules/payment/payment.controller.ts` | `/api/v1/payment/gateway/webhook` 等端点 |
| `backend/src/modules/orders/orders.service.ts` | 下单路由,统一走网关 |
| `backend/.env` | `GATEWAY_PAY_*` 配置项 |
### API 端点一览
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| POST | `/api/v1/payment/gateway/webhook` | 网关支付成功/退款回调 | 无(网关签名在 body 中) |
| GET | `/api/v1/payment/wxpay/query?outTradeNo=xxx` | 订单状态查询(转网关) | JWT |
| POST | `/api/v1/payment/wxpay/refund` | 退款(转网关) | JWT |
| POST | `/api/v1/orders/create` | 创建订单+发起支付 | JWT |
| GET | `/api/v1/admin/orders?page=&search=&status=&payChannel=` | 管理员订单搜索 | Admin JWT |
| POST | `/api/v1/admin/orders/:orderNo/refund` | 管理员退款 | Admin JWT |
| POST | `/api/v1/admin/orders/:orderNo/mark-paid` | 标记人工已付 | Admin JWT |
### 商户密钥文件
备份位置: `/root/hermes-workspace/backup/网关支付/pay-api/merchants/AI学习平台.key`
---
## 2026-05-31 Session (下午)
### Changes Made
- **AI 助手架构升级**: 用户端 + 管理后台双 AI 助手均采用完整 Tool Calling 架构
- **新增 `UserAiAssistantService`**: 后台工具执行服务,18 个工具(搜索/技能/学情/通知/个人信息/模型/提示词/课程/订单)
- **新增 `AiAssistantController`**: `POST /api/v1/ai-assistant/chat` + `/action` 端点
- **新增 `AiAssistantModule`**: 注册 SkillsModule + AIModule 依赖
- **重写前端 `ai-assistant.tsx`**: Tool Calling 架构 + 页面感知上下文 + 登录控制
- **更新前端 `admin-ai-assistant.tsx`**: 新增 search/mark-all-notifications-read 工具描述 + 更多 starter
- **新增后台 `search`/`mark-all-notifications-read` 工具**: 全局搜索用户/订单/课程/内容 + 通知批量已读
- **重构 `assistant-actions.ts`**: 统一 `executeClientAction()` API,支持所有客户端操作
- **清理 `assistant-context.ts`**: 精简为纯上下文数据源,移除旧 action 格式
### 用户端 AI 助手可用操作
| 分类 | 工具 |
|------|------|
| 🧭 导航 | `navigate` — 跳转到任意页面 |
| 🔍 搜索 | `search` — 全局搜索课程/提示词/工具/文章 |
| 🎯 技能 | `list-skills` / `get-skill` / `get-skill-categories` |
| 📊 学情 | `get-learning-analytics` / `get-learning-path` |
| 🔔 通知 | `list-notifications` / `get-unread-count` / `mark-notification-read` / `mark-all-read` |
| 👤 个人 | `get-profile` / `list-user-orders` / `get-current-subscription` |
| 📚 内容 | `list-models` / `list-prompts` / `list-courses` |
| ⚡ 客户端 | `setModel` / `startChat` / `openSkill` / `setParameter` |
### 管理后台新增工具
| 工具 | 说明 |
|------|------|
| `search` | 全局搜索用户/订单/课程/内容 |
| `mark-all-notifications-read` | 批量标记所有通知已读 |
### Verification
- ✅ 后端 92 项测试全部通过
- ✅ 后端 TypeScript 编译无错误
- ✅ 前端 75 静态页面全部生成成功
- ✅ 所有文件创建/修改完成
---
- `frontend/src/lib/models.ts` 作为模型列表单数据源
- 所有颜色使用 CSS 变量(`text-foreground`/`text-muted-foreground`/`bg-card`/`bg-muted`/`border-border`
- 沙盒 JWT 认证保留
- 支付全部通过 yzrcloud.cn 统一网关处理,支持支付宝/微信
- 运营助手采用 Tool Calling 架构
- 前端静态导出 (`output: 'export'`) + Nginx 直接提供
- 注册支持用户名、手机号、邮箱三选一,登录支持用户名/手机号/邮箱
- 注册时校验重复用户名/手机号/邮箱并返回友好提示
- 镜像站点 `www.yuzhiran.com.cn` 与主站配置同步
---
## 2026-06-01 Session — 注册优化 + 镜像站部署
### Changes Made
- **Prisma User 模型**: 新增 `username` 字段 (`String? @unique`)
- **注册 DTO**: 新增 `username` 字段验证(2-20位,支持中英文/数字/下划线)
- **注册逻辑重构** (`auth.service.ts`):
- 注册前分别检查 username/phone/email 是否已存在 → 友好提示
- 三字段至少需提供一个
- 注册成功用 username 做默认 nickname
- **登录逻辑** (`auth.service.ts`): `OR` 查询增加 `{ username: account }`
- **前端 i18n**: 新增 `username`/`usernameOptional`/`usernameConflict`/`phoneConflict`/`emailConflict` 翻译 key
- **前端 auth 页**:
- 注册表单增加用户名输入框
- 前端预校验用户名格式(2-20位,中英文/数字/下划线)
- 友好错误映射:覆盖用户名/手机号/邮箱冲突提示
- 登录框提示改为 "用户名 / 手机号 / 邮箱"
- **镜像站部署**:
- 前端静态文件部署至 `/www/wwwroot/www.yuzhiran.com.cn/`
- Nginx vhost 配置同步主站:Next.js clean URL + API proxy + 静态缓存
- Nginx reload + PM2 restart
### Verification
- ✅ Prisma db push 成功(`username` 列 + 唯一索引)
- ✅ 后端 92 项测试全部通过
- ✅ 后端 TypeScript 编译无错误
- ✅ 前端 75 静态页面全部生成成功
- ✅ Nginx 配置语法验证通过
- ✅ 主站 + 镜像站均已部署
---
## 2026-06-01 Session — 支付功能对齐网关文档 + 完善管理操作
### Changes Made
- **GatewayPayService 对齐网关文档**:
- `createOrder()` 新增 `remark` 参数(传入 `{"uid":userId,"oid":orderId}`),移除废弃的 `return_url`
- 新增 `closeOrder(gatewayOrderId)` — 调用 `POST /v1/pay/orders/{id}/close`
- 新增 `syncOrderStatus(orderNo)` — 从网关查询订单状态,若 `paid` 则自动更新本地 + 激活订阅
- **OrdersService**: 下单时传入 `remark`(含 uid/oid),使 webhook 回调可正确匹配用户和订单
- **PaymentController**:
- 新增 `POST /payment/gateway/sync/:orderNo` — 手动同步单笔订单状态
- 新增 `POST /payment/gateway/close/:gatewayOrderId` — 关闭未支付订单
- 清理未使用的 `PaymentService` 依赖
- **管理后台订单页** (`admin/orders/page.tsx`):
- 待支付订单增加「同步状态」按钮 — 调用网关查询后更新
- **文档**: `docs/progress.md` 支付部署架构更新为统一网关模式
### Payment 架构现状
### 技能购买流程
```
下单: 会员页 → POST /orders/create → OrdersService → GatewayPayService → yzrcloud.cn 网关
查询: POST /payment/gateway/sync/:orderNo → GatewayPayService.syncOrderStatus()
关闭: POST /payment/gateway/close/:id → GatewayPayService.closeOrder()
退款: POST /admin/orders/:orderNo/refund → GatewayPayService.refund()
回调: POST /payment/gateway/webhook → GatewayPayService.handleWebhook()
用户点击"解锁" → POST /orders/create (planType=SKILL, skillId=xxx)
→ mock支付自动完成
→ UserSkill 记录创建
→ 技能广场页显示"已拥有"
→ 用户可直接在沙盒使用
```
### Gaps Identified (远期)
- `payment.service.ts` (微信直连) 和 `alipay.service.ts` (支付宝直连) 为遗留代码,已被 GatewayPayService 全面替代,待确认无人依赖后可移除
- 课程购买页 (`courses/[id]/client.tsx`) 仍直接调用 `wxpay/unified-order` 绕过 OrdersService,未传入 `remark`
### Verification
- ✅ 后端 92 项测试全部通过
- ✅ 后端 TypeScript 编译无错误
- ✅ 前端 75 静态页面全部生成成功
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
+2 -1
View File
@@ -2,7 +2,8 @@
const nextConfig = {
output: 'export',
images: { unoptimized: true },
typescript: { ignoreBuildErrors: true },
eslint: { ignoreDuringBuilds: true },
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || '/api/v1',
NEXT_PUBLIC_SITE_URL: 'https://yuzhiran.com',
+39 -122
View File
@@ -26,6 +26,7 @@
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.9",
"framer-motion": "^12.38.0",
"highlight.js": "^11.11.1",
"lucide-react": "^1.14.0",
@@ -35,6 +36,7 @@
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-is": "^18.3.1",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@@ -45,12 +47,12 @@
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^8.57.1",
"eslint-config-next": "^16.2.6",
"jsdom": "^29.1.1",
@@ -1153,7 +1155,7 @@
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"devOptional": true,
"dev": true,
"dependencies": {
"playwright": "1.60.0"
},
@@ -3372,27 +3374,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -3459,14 +3440,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -3539,6 +3512,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3575,7 +3558,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3585,12 +3568,19 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -4179,39 +4169,6 @@
"win32"
]
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.7"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
@@ -4374,20 +4331,6 @@
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -5118,7 +5061,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -5443,13 +5386,14 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"node_modules/dompurify": {
"version": "3.4.9",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -8090,17 +8034,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -8720,7 +8653,7 @@
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"devOptional": true,
"dev": true,
"dependencies": {
"playwright-core": "1.60.0"
},
@@ -8738,7 +8671,7 @@
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"devOptional": true,
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -8750,6 +8683,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -8943,22 +8877,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
@@ -9050,11 +8968,10 @@
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT",
"peer": true
"version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.3.0",
+3 -1
View File
@@ -43,6 +43,7 @@
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.9",
"framer-motion": "^12.38.0",
"highlight.js": "^11.11.1",
"lucide-react": "^1.14.0",
@@ -52,6 +53,7 @@
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-is": "^18.3.1",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@@ -62,12 +64,12 @@
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^8.57.1",
"eslint-config-next": "^16.2.6",
"jsdom": "^29.1.1",
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { toast } from 'sonner';
import { TrendingUp, TrendingDown, Users, ShoppingCart, DollarSign, BookOpen, MessageCircle } from 'lucide-react';
import { API_BASE } from '@/lib/config';
@@ -195,7 +196,7 @@ export default function AnalyticsPage() {
<QuickAction
label="导出报告"
desc="下载数据报表"
onClick={() => alert('导出功能开发中')}
onClick={() => toast.info('导出功能开发中')}
/>
</div>
</div>
+4 -3
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface PendingComment {
id: number;
@@ -37,7 +38,7 @@ export default function AdminCommentsPage() {
const data = await res.json();
setComments(data.items || []);
}
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
setLoading(false);
}, [tab, base]);
@@ -49,7 +50,7 @@ export default function AdminCommentsPage() {
method: 'PUT', headers: headers(),
});
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
async function reject(id: number) {
@@ -60,7 +61,7 @@ export default function AdminCommentsPage() {
method: 'PUT', headers: headers(), body: JSON.stringify({ reason }),
});
if (res.ok) setComments(prev => prev.filter(c => c.id !== id));
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
return (
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
export default function EditModel() {
const router = useRouter();
const params = useParams();
const [form, setForm] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models/${params.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setForm({
name: data.name, provider: data.provider, description: data.description || '',
capabilities: data.capabilities || '', contextWindow: data.contextWindow || 4096,
maxTokens: data.maxTokens || 2048, pricing: data.pricing || '',
isFree: data.isFree ?? true, isFeatured: data.isFeatured ?? false,
icon: data.icon || '', sortOrder: data.sortOrder || 0, status: data.status || 'ACTIVE',
});
}
} catch {}
setLoading(false);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...form, contextWindow: Number(form.contextWindow), maxTokens: Number(form.maxTokens), sortOrder: Number(form.sortOrder) }),
});
if (res.ok) {
toast.success('更新成功');
router.push('/admin/models');
} else {
const err = await res.json();
toast.error(err.message || '更新失败');
}
} catch { toast.error('网络错误'); }
}
if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>;
if (!form) return <div className="p-6 text-muted-foreground"></div>;
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={3}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.capabilities} onChange={e => setForm({ ...form, capabilities: e.target.value })} />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.contextWindow} onChange={e => setForm({ ...form, contextWindow: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Max Tokens</label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.maxTokens} onChange={e => setForm({ ...form, maxTokens: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> Emoji</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.icon} onChange={e => setForm({ ...form, icon: e.target.value })} />
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFree}
onChange={e => setForm({ ...form, isFree: e.target.checked })} />
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFeatured}
onChange={e => setForm({ ...form, isFeatured: e.target.checked })} />
</label>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,19 @@
import { API_BASE } from '@/lib/config';
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/admin/models?pageSize=100`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((t: any) => ({ id: String(t.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
export default function NewModel() {
const router = useRouter();
const [form, setForm] = useState({
name: '', provider: '', description: '', capabilities: '',
contextWindow: 4096, maxTokens: 2048, pricing: '',
isFree: true, isFeatured: false, icon: '', sortOrder: 0, status: 'ACTIVE',
});
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...form, contextWindow: Number(form.contextWindow), maxTokens: Number(form.maxTokens), sortOrder: Number(form.sortOrder) }),
});
if (res.ok) {
toast.success('创建成功');
router.push('/admin/models');
} else {
const err = await res.json();
toast.error(err.message || '创建失败');
}
} catch { toast.error('网络错误'); }
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> *</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={3}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="chat,code,reasoning"
value={form.capabilities} onChange={e => setForm({ ...form, capabilities: e.target.value })} />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.contextWindow} onChange={e => setForm({ ...form, contextWindow: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Max Tokens</label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.maxTokens} onChange={e => setForm({ ...form, maxTokens: Number(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.pricing} onChange={e => setForm({ ...form, pricing: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"> Emoji</label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.icon} onChange={e => setForm({ ...form, icon: e.target.value })} />
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFree}
onChange={e => setForm({ ...form, isFree: e.target.checked })} />
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isFeatured}
onChange={e => setForm({ ...form, isFeatured: e.target.checked })} />
</label>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface AiModel {
id: number;
name: string;
provider: string;
isFree: boolean;
isFeatured: boolean;
status: string;
sortOrder: number;
}
export default function AdminModels() {
const router = useRouter();
const [items, setItems] = useState<AiModel[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/models?pageSize=100`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleFeatured(id: number, current: boolean) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ isFeatured: !current }),
});
load();
} catch { toast.error('操作失败'); }
}
async function del(id: number) {
if (!confirm('确定删除此模型?')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/models/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
toast.success('已删除');
load();
} catch { toast.error('删除失败'); }
}
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"> AI </p>
</div>
<Link href="/admin/models/new"
className="rounded-lg bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90">
</Link>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">ID</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={8} className="text-center py-12 text-muted-foreground"></td></tr>
)}
{items.map(item => (
<tr key={item.id} className="border-b border-border hover:bg-muted/20">
<td className="px-4 py-3">{item.id}</td>
<td className="px-4 py-3 font-medium">{item.name}</td>
<td className="px-4 py-3 text-muted-foreground">{item.provider}</td>
<td className="px-4 py-3">{item.isFree ? '✅' : '💰'}</td>
<td className="px-4 py-3">
<button onClick={() => toggleFeatured(item.id, item.isFeatured)}
className={`text-xs px-2 py-0.5 rounded ${item.isFeatured ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'}`}>
{item.isFeatured ? '推荐' : '普通'}
</button>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded ${
item.status === 'ACTIVE' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}>{item.status}</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{item.sortOrder}</td>
<td className="px-4 py-3 text-right space-x-2">
<Link href={`/admin/models/${item.id}`}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"></Link>
<button onClick={() => del(item.id)}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Banner {
id: number;
@@ -37,7 +38,7 @@ export default function BannersPage() {
}
async function createBanner() {
if (!form.title || !form.image) return alert('请填写标题和图片');
if (!form.title || !form.image) return toast.error('请填写标题和图片');
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/banners`, {
method: 'POST',
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Notification {
id: number;
@@ -37,7 +38,7 @@ export default function NotificationsPage() {
}
async function sendNotification() {
if (!form.title || !form.content) return alert('请填写标题和内容');
if (!form.title || !form.content) return toast.error('请填写标题和内容');
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/notifications`, {
method: 'POST',
+11 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Order {
id: number;
@@ -80,15 +81,15 @@ export default function AdminOrders() {
body: JSON.stringify({ amount: order.amount, reason: refundModal.reason }),
});
if (res.ok) {
alert('退款成功');
toast.success('退款成功');
setRefundModal({ open: false, order: null, reason: '', loading: false });
loadOrders();
} else {
const err = await res.json();
alert(err.message || '退款失败');
toast.error(err.message || '退款失败');
}
} catch {
alert('退款失败,请重试');
toast.error('退款失败,请重试');
}
}
@@ -101,16 +102,16 @@ export default function AdminOrders() {
if (res.ok) {
const data = await res.json();
if (data.updated) {
alert('订单状态已同步更新为已支付');
toast.success('订单状态已同步更新为已支付');
} else {
alert(`状态同步完成:当前状态 ${data.status}`);
toast.info(`状态同步完成:当前状态 ${data.status}`);
}
loadOrders();
} else {
const err = await res.json();
alert(err.message || '同步失败');
toast.error(err.message || '同步失败');
}
} catch { alert('同步失败,请重试'); }
} catch { toast.error('同步失败,请重试'); }
}
async function handleMarkPaid(orderNo: string) {
@@ -121,14 +122,14 @@ export default function AdminOrders() {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
alert('已标记为已支付');
toast.success('已标记为已支付');
loadOrders();
} else {
const err = await res.json();
alert(err.message || '操作失败');
toast.error(err.message || '操作失败');
}
} catch {
alert('操作失败');
toast.error('操作失败');
}
}
+3
View File
@@ -24,6 +24,9 @@ const links = [
{ href: '/admin/tools', title: '工具管理', desc: '管理AI工具库', color: 'from-cyan-500 to-cyan-600' },
{ href: '/admin/orders', title: '订单管理', desc: '查看支付订单', color: 'from-rose-500 to-rose-600' },
{ href: '/admin/enterprise', title: '企业版管理', desc: '管理组织、成员和学习报告', color: 'from-indigo-500 to-indigo-600' },
{ href: '/admin/practices', title: '练习管理', desc: '管理练习题库', color: 'from-teal-500 to-teal-600' },
{ href: '/admin/posts', title: '帖子管理', desc: '管理社区帖子', color: 'from-sky-500 to-sky-600' },
{ href: '/admin/models', title: '模型管理', desc: '管理AI模型', color: 'from-violet-500 to-violet-600' },
{ href: '/admin/comments', title: '评论审核', desc: '审核社区评论', color: 'from-pink-500 to-pink-600' },
{ href: '/admin/operations/banners', title: 'Banner管理', desc: '管理首页横幅', color: 'from-emerald-500 to-emerald-600' },
{ href: '/admin/operations/notifications', title: '推送管理', desc: '系统推送通知', color: 'from-red-500 to-red-600' },
@@ -0,0 +1,90 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
export default function AdminPostDetail() {
const params = useParams();
const router = useRouter();
const [post, setPost] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/posts/${params.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) setPost(await res.json());
} catch {}
setLoading(false);
}
async function updateStatus(status: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${params.id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status }),
});
toast.success('状态已更新');
load();
} catch { toast.error('操作失败'); }
}
async function del() {
if (!confirm('确定删除此帖子?')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${params.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
toast.success('已删除');
router.push('/admin/posts');
} catch { toast.error('删除失败'); }
}
if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-40 w-full" /></div>;
if (!post) return <div className="p-6 text-muted-foreground"></div>;
return (
<div className="p-6 max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex gap-2">
{post.status === 'PUBLISHED' ? (
<button onClick={() => updateStatus('HIDDEN')}
className="px-3 py-1.5 rounded-lg bg-orange-500 text-white text-sm hover:bg-orange-600"></button>
) : (
<button onClick={() => updateStatus('PUBLISHED')}
className="px-3 py-1.5 rounded-lg bg-green-500 text-white text-sm hover:bg-green-600"></button>
)}
<button onClick={del}
className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-sm hover:bg-red-600"></button>
</div>
</div>
<div className="bg-card rounded-xl border border-border p-6 space-y-4">
<div>
<div className="text-xs text-muted-foreground mb-1">
{post.user?.nickname || '匿名'} ·
{new Date(post.createdAt).toLocaleString('zh-CN')} ·
👁 {post.viewCount} · 💬 {post._count?.comments || 0} · {post._count?.likes || 0}
</div>
<h2 className="text-xl font-bold text-foreground">{post.title}</h2>
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">{post.content}</div>
</div>
<button onClick={() => router.back()}
className="mt-4 text-sm text-muted-foreground hover:text-foreground">&larr; </button>
</div>
);
}
@@ -0,0 +1,19 @@
import { API_BASE } from '@/lib/config';
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/admin/posts?pageSize=100`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((t: any) => ({ id: String(t.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
+151
View File
@@ -0,0 +1,151 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Post {
id: number;
title: string;
status: string;
viewCount: number;
createdAt: string;
user: { id: number; nickname: string };
_count: { comments: number; likes: number };
}
export default function AdminPosts() {
const [items, setItems] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
useEffect(() => { load(); }, [statusFilter]);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const params = new URLSearchParams({ pageSize: '100' });
if (statusFilter) params.set('status', statusFilter);
const res = await fetch(`${API_BASE}/admin/posts?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
}
} catch {}
setLoading(false);
}
async function updateStatus(id: number, status: string) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ status }),
});
toast.success('状态已更新');
load();
} catch { toast.error('操作失败'); }
}
async function del(id: number) {
if (!confirm('确定删除此帖子?此操作不可恢复。')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/posts/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
toast.success('已删除');
load();
} catch { toast.error('删除失败'); }
}
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4">
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="p-6">
<div className="flex gap-2 mb-4">
{['', 'PUBLISHED', 'HIDDEN'].map(s => (
<button key={s} onClick={() => setStatusFilter(s)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === s ? 'bg-foreground text-background' : 'bg-muted text-muted-foreground hover:text-foreground'
}`}>
{s || '全部'}
</button>
))}
</div>
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">ID</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground"></td></tr>
)}
{items.map(item => (
<tr key={item.id} className="border-b border-border hover:bg-muted/20">
<td className="px-4 py-3">{item.id}</td>
<td className="px-4 py-3 font-medium max-w-xs truncate">{item.title}</td>
<td className="px-4 py-3 text-muted-foreground">{item.user?.nickname || '匿名'}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
item.status === 'PUBLISHED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
item.status === 'HIDDEN' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}>{item.status}</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
💬 {item._count?.comments || 0} · {item._count?.likes || 0} · 👁 {item.viewCount}
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link href={`/admin/posts/${item.id}`}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"></Link>
{item.status === 'PUBLISHED' ? (
<button onClick={() => updateStatus(item.id, 'HIDDEN')}
className="text-xs text-orange-600 hover:text-orange-700 dark:text-orange-400"></button>
) : (
<button onClick={() => updateStatus(item.id, 'PUBLISHED')}
className="text-xs text-green-600 hover:text-green-700 dark:text-green-400"></button>
)}
<button onClick={() => del(item.id)}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
@@ -0,0 +1,99 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
export default function EditPractice() {
const router = useRouter();
const params = useParams();
const [form, setForm] = useState({ title: '', description: '', difficulty: 'BEGINNER', category: '', sortOrder: 0, isActive: true });
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices/${params.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setForm({ title: data.title, description: data.description || '', difficulty: data.difficulty || 'BEGINNER', category: data.category || '', sortOrder: data.sortOrder || 0, isActive: data.isActive ?? true });
}
} catch {}
setLoading(false);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) {
toast.success('更新成功');
router.push('/admin/practices');
} else {
const err = await res.json();
toast.error(err.message || '更新失败');
}
} catch { toast.error('网络错误'); }
}
if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>;
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={4}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.difficulty} onChange={e => setForm({ ...form, difficulty: e.target.value })}>
<option value="BEGINNER"></option>
<option value="INTERMEDIATE"></option>
<option value="ADVANCED"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="isActive" checked={form.isActive}
onChange={e => setForm({ ...form, isActive: e.target.checked })} />
<label htmlFor="isActive" className="text-sm text-foreground"></label>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,19 @@
import { API_BASE } from '@/lib/config';
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/admin/practices?pageSize=100`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((t: any) => ({ id: String(t.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
@@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
export default function NewPractice() {
const router = useRouter();
const [form, setForm] = useState({ title: '', description: '', difficulty: 'BEGINNER', category: '', sortOrder: 0 });
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
});
if (res.ok) {
toast.success('创建成功');
router.push('/admin/practices');
} else {
const err = await res.json();
toast.error(err.message || '创建失败');
}
} catch {
toast.error('网络错误');
}
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" required
value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={4}
value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.difficulty} onChange={e => setForm({ ...form, difficulty: e.target.value })}>
<option value="BEGINNER"></option>
<option value="INTERMEDIATE"></option>
<option value="ADVANCED"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input type="number" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={form.sortOrder} onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="rounded-lg bg-primary text-primary-foreground px-6 py-2 text-sm font-medium hover:bg-primary/90"></button>
<button type="button" onClick={() => router.back()} className="rounded-lg border border-border px-6 py-2 text-sm font-medium hover:bg-accent"></button>
</div>
</form>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
interface Practice {
id: number;
title: string;
description?: string;
difficulty: string;
category?: string;
sortOrder: number;
isActive: boolean;
}
export default function AdminPractices() {
const router = useRouter();
const [items, setItems] = useState<Practice[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { load(); }, []);
async function load() {
try {
const token = localStorage.getItem('adminToken');
const res = await fetch(`${API_BASE}/admin/practices?pageSize=100`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
}
} catch {}
setLoading(false);
}
async function toggleActive(id: number, current: boolean) {
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/practices/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ isActive: !current }),
});
load();
} catch {}
}
async function del(id: number) {
if (!confirm('确定删除此练习?')) return;
try {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/practices/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
load();
} catch {}
}
if (loading) return (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
return (
<>
<div className="border-b border-border bg-card px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
<Link href="/admin/practices/new"
className="rounded-lg bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90">
</Link>
</div>
<div className="p-6">
<div className="bg-card rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">ID</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground"></td></tr>
)}
{items.map(item => (
<tr key={item.id} className="border-b border-border hover:bg-muted/20">
<td className="px-4 py-3">{item.id}</td>
<td className="px-4 py-3 font-medium">{item.title}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
item.difficulty === 'BEGINNER' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
item.difficulty === 'INTERMEDIATE' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}>
{item.difficulty}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{item.category || '-'}</td>
<td className="px-4 py-3 text-muted-foreground">{item.sortOrder}</td>
<td className="px-4 py-3">
<button onClick={() => toggleActive(item.id, item.isActive)}
className={`text-xs px-2 py-1 rounded ${item.isActive ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'}`}>
{item.isActive ? '启用' : '禁用'}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link href={`/admin/practices/${item.id}`}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"></Link>
<button onClick={() => del(item.id)}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import * as Dialog from '@/components/ui/dialog';
import { toast } from 'sonner';
interface Role {
id: string;
@@ -53,13 +54,13 @@ export default function SettingsRolesPage() {
]);
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
if (permsRes.ok) setPermissions((await permsRes.json()).items || []);
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setLoading(false);
}, []);
useEffect(() => { loadData(); }, [loadData]);
const categories = [...new Set(permissions.map(p => p.category))];
const categories = Array.from(new Set(permissions.map(p => p.category)));
if (loading) {
return <div className="p-6 text-center text-muted-foreground">...</div>;
@@ -195,7 +196,7 @@ function RoleDialog({ role, permissions, categories, open, onOpenChange, onSaved
});
}
onSaved();
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setSaving(false);
}
@@ -276,7 +277,7 @@ function AdminsTab({ onReload }: { onReload: () => void }) {
]);
if (adminsRes.ok) setAdmins((await adminsRes.json()).items || []);
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setLoading(false);
}, []);
@@ -379,7 +380,7 @@ function AdminDialog({ admin, roles, open, onOpenChange, onSaved }: {
});
}
onSaved();
} catch (e) { console.error(e); }
} catch { toast.error("操作失败") }
setSaving(false);
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface User {
id: number;
@@ -40,7 +41,7 @@ export default function UsersPage() {
}
async function createUser() {
if (!form.phone || !form.password) return alert('手机号和密码必填');
if (!form.phone || !form.password) return toast.error('手机号和密码必填');
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/users`, {
method: 'POST',
+9 -6
View File
@@ -4,7 +4,9 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { getToken } from '@/lib/auth';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface Post {
id: number;
@@ -32,7 +34,7 @@ export default function CircleDetail() {
async function loadData() {
try {
const token = localStorage.getItem('token');
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
@@ -56,7 +58,7 @@ export default function CircleDetail() {
setIsMember(memData.isMember);
}
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -64,21 +66,20 @@ export default function CircleDetail() {
const token = localStorage.getItem('token');
if (!token) return;
const method = isMember ? 'POST' : 'POST';
const url = isMember
? `${API_BASE}/circles/${circleId}/leave`
: `${API_BASE}/circles/${circleId}/join`;
try {
const res = await fetch(url, {
method,
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (res.ok) {
setIsMember(!isMember);
loadData();
}
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
async function handleCreatePost() {
@@ -96,8 +97,10 @@ export default function CircleDetail() {
setFormContent('');
setShowCreateForm(false);
loadData();
} else {
toast.error("发帖失败");
}
} catch (e) { console.error(e) }
} catch { toast.error("发帖失败") }
}
if (loading) return (
+9 -6
View File
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
import { useT } from '@/i18n';
interface Circle {
id: number;
@@ -15,6 +17,7 @@ interface Circle {
}
export default function CirclesPage() {
const t = useT();
const [circles, setCircles] = useState<Circle[]>([]);
const [loading, setLoading] = useState(true);
@@ -27,7 +30,7 @@ export default function CirclesPage() {
const data = await res.json();
setCircles(data || []);
}
} catch (e) { console.error(e) }
} catch { toast.error("加载圈子失败") }
setLoading(false);
}
@@ -43,9 +46,9 @@ export default function CirclesPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; </Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">&larr; {t.circles.back}</Link>
<h1 className="text-3xl font-bold text-foreground">{t.circles.title}</h1>
<p className="mt-2 text-muted-foreground">{t.circles.desc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -60,14 +63,14 @@ export default function CirclesPage() {
<span key={tag} className="text-xs px-2 py-0.5 bg-muted rounded">{tag.trim()}</span>
))}
</div>
<span className="text-xs text-muted-foreground">{circle._count?.members || 0} · {circle._count?.posts || 0} </span>
<span className="text-xs text-muted-foreground">{circle._count?.members || 0} {t.circles.members} · {circle._count?.posts || 0} {t.circles.posts}</span>
</div>
</Link>
))}
</div>
{circles.length === 0 && !loading && (
<div className="text-center py-20 text-muted-foreground"><p></p></div>
<div className="text-center py-20 text-muted-foreground"><p>{t.circles.empty}</p></div>
)}
</div>
);
+4 -3
View File
@@ -8,6 +8,7 @@ import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { toast } from 'sonner';
interface PostDetail {
id: number; title: string; content: string; tags?: string;
@@ -37,7 +38,7 @@ export default function CommunityPostDetail() {
try {
const res = await apiFetch(`/community/posts/${params.id}`);
if (res.ok) setPost(await res.json());
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -51,7 +52,7 @@ export default function CommunityPostDetail() {
body: JSON.stringify({ content: commentText }),
});
if (res.ok) { setCommentText(''); loadPost(); }
} catch (e) { console.error(e) }
} catch { toast.error("评论失败") }
setSubmitting(false);
}
@@ -59,7 +60,7 @@ export default function CommunityPostDetail() {
try {
await apiFetch(`/community/posts/${params.id}/like`, { method: 'POST' });
loadPost();
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
if (loading) return (
+9 -8
View File
@@ -3,11 +3,12 @@
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "../../lib/auth";
import { apiFetch, getToken } from "../../lib/auth";
import { useAuth } from "@/lib/auth-context";
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface Post { id: number; title: string; content: string; tags?: string | null; viewCount: number; likeCount: number; commentCount: number; createdAt: string; user: { id: number; nickname: string; avatar: string | null }; comments?: Comment[] }
interface Comment { id: number; content: string; createdAt: string; user: { id: number; nickname: string; avatar: string | null } }
@@ -29,12 +30,12 @@ export default function CommunityPage() {
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const token = localStorage.getItem('token');
const token = getToken();
const url = activeTab === 'feed' && token ? '/community/feed' : '/community/posts';
const res = await apiFetch(url);
const data = await res.json();
setPosts(data.items || []);
} catch (e) { console.error(e) }
} catch { toast.error("数据加载失败") }
setLoading(false);
}, [activeTab]);
@@ -50,7 +51,7 @@ export default function CommunityPage() {
try {
const res = await apiFetch(`/community/users/${post.user.id}/follow`);
if (res.ok) { const d = await res.json(); if (d.followed) followed.add(post.user.id); }
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
}
setFollowedUsers(followed);
@@ -64,12 +65,12 @@ export default function CommunityPage() {
await apiFetch("/community/posts", { method: "POST", body: JSON.stringify({ title, content, tags: tags || undefined }) });
setTitle(""); setContent(""); setTags(""); setShowForm(false);
loadPosts();
} catch (e) { console.error(e) }
} catch { toast.error("发帖失败") }
setSubmitting(false);
}
async function handleLike(postId: number) {
try { await apiFetch(`/community/posts/${postId}/like`, { method: "POST" }); loadPosts(); } catch (e) { console.error(e) }
try { await apiFetch(`/community/posts/${postId}/like`, { method: "POST" }); loadPosts(); } catch { toast.error("操作失败") }
}
async function handleFollow(userId: number) {
@@ -78,7 +79,7 @@ export default function CommunityPage() {
const isFollowed = followedUsers.has(userId);
await apiFetch(`/community/users/${userId}/follow`, { method: isFollowed ? 'DELETE' : 'POST' });
setFollowedUsers(prev => { const next = new Set(prev); isFollowed ? next.delete(userId) : next.add(userId); return next; });
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
function PostCard({ post }: { post: Post }) {
@@ -93,7 +94,7 @@ export default function CommunityPage() {
try {
await apiFetch(`/community/posts/${post.id}/comments`, { method: "POST", body: JSON.stringify({ content: comment }) });
setComment(""); loadPosts();
} catch (e) { console.error(e) }
} catch { toast.error("评论失败") }
setSubmittingComment(false);
}
+2 -10
View File
@@ -81,7 +81,7 @@ export default function CourseDetailClient() {
<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 && qrUrl !== 'mock://pay' ? (
{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="支付二维码" />
@@ -89,7 +89,7 @@ export default function CourseDetailClient() {
<p className="text-sm text-muted-foreground mb-4">使</p>
</>
) : (
<p className="text-muted-foreground mb-4"></p>
<p className="text-sm text-muted-foreground mb-4">...</p>
)}
<div className="flex gap-3 justify-center">
<button
@@ -98,14 +98,6 @@ export default function CourseDetailClient() {
>
</button>
{qrUrl === 'mock://pay' && (
<button
onClick={() => { setPaying(false); setQrCodeUrl(''); alert('模拟支付成功!'); }}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700"
>
</button>
)}
</div>
</div>
</div>
+2 -2
View File
@@ -1,10 +1,10 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { toast } from 'sonner';
interface DailyItem {
id: number;
@@ -41,7 +41,7 @@ export default function DailyPage() {
];
setItems(items);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+2 -2
View File
@@ -1,10 +1,10 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { toast } from 'sonner';
interface HotItem {
id: number;
@@ -45,7 +45,7 @@ export default function HotPage() {
}).slice(0, 20);
setItems(items);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+2 -1
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface HotItem { id: number; title: string; viewCount: number; likeCount: number; _type: 'course' | 'prompt' | 'post' }
@@ -25,7 +26,7 @@ export default function DiscoverPage() {
setHotCourses((await coursesRes.json()).items?.map((c: any) => ({ ...c, _type: 'course' as const })) || []);
setHotPrompts((await promptsRes.json()).items?.map((p: any) => ({ ...p, _type: 'prompt' as const })) || []);
setHotPosts((await postsRes.json()).items?.map((p: any) => ({ ...p, _type: 'post' as const })) || []);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+6 -4
View File
@@ -1,10 +1,11 @@
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { toast } from 'sonner';
import { useT } from '@/i18n';
interface Tool {
id: number;
@@ -16,6 +17,7 @@ interface Tool {
}
export default function ToolsRecommendPage() {
const t = useT();
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
@@ -28,7 +30,7 @@ export default function ToolsRecommendPage() {
const res = await apiFetch('/tools?pageSize=10');
const data = await res.json();
setTools(data.items || []);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -49,8 +51,8 @@ export default function ToolsRecommendPage() {
<Link href="/discover" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr;
</Link>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground">AI工具</p>
<h1 className="text-3xl font-bold text-foreground">{t.discover.toolsTitle}</h1>
<p className="mt-2 text-muted-foreground">{t.discover.toolsDesc}</p>
</div>
<div className="grid gap-4">
@@ -0,0 +1,342 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { useAuth } from '@/lib/auth-context';
import { apiFetch, getToken } from '@/lib/auth';
import { Building2, Users, BarChart3, Plus, X, LogIn } from 'lucide-react';
import { toast } from 'sonner';
interface Org {
id: number;
name: string;
description: string | null;
contactName: string | null;
memberCount: number;
status: string;
createdAt: string;
members?: OrgMember[];
_count?: { members: number; assignments: number };
}
interface OrgMember {
id: number;
userId: number;
role: string;
user: { id: number; nickname: string | null; email: string | null; phone: string | null; avatar: string | null };
}
interface OrgReport {
organization: Org;
totalMembers: number;
totalCourses: number;
completedLessons: number;
completionRate: number;
memberProgress: { userId: number; nickname: string; completed: number }[];
}
export default function EnterpriseDashboardPage() {
const t = useT();
const { isLoggedIn } = useAuth();
const [orgs, setOrgs] = useState<Org[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOrg, setSelectedOrg] = useState<Org | null>(null);
const [orgMembers, setOrgMembers] = useState<OrgMember[]>([]);
const [orgReport, setOrgReport] = useState<OrgReport | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newOrgName, setNewOrgName] = useState('');
const [newOrgDesc, setNewOrgDesc] = useState('');
const [creating, setCreating] = useState(false);
const [inviteUserId, setInviteUserId] = useState('');
const [inviting, setInviting] = useState(false);
const [activeTab, setActiveTab] = useState<'members' | 'report'>('members');
useEffect(() => {
if (!isLoggedIn) { setLoading(false); return; }
loadOrgs();
}, [isLoggedIn]);
async function loadOrgs() {
try {
const res = await apiFetch('/enterprise/my');
if (res.ok) {
const data = await res.json();
setOrgs(data || []);
}
} catch { /* ignore */ }
setLoading(false);
}
async function selectOrg(org: Org) {
setSelectedOrg(org);
setActiveTab('members');
try {
const [detailRes, reportRes] = await Promise.all([
apiFetch(`/enterprise/organizations/${org.id}`),
apiFetch(`/enterprise/organizations/${org.id}/report`),
]);
if (detailRes.ok) {
const data = await detailRes.json();
setOrgMembers(data.members || []);
}
if (reportRes.ok) {
const data = await reportRes.json();
setOrgReport(data);
}
} catch { toast.error("加载组织信息失败") }
}
async function createOrg() {
if (!newOrgName.trim()) return;
setCreating(true);
try {
const res = await apiFetch('/enterprise/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newOrgName.trim(), description: newOrgDesc.trim() || undefined }),
});
if (res.ok) {
setShowCreateForm(false);
setNewOrgName('');
setNewOrgDesc('');
loadOrgs();
} else {
const err = await res.json();
toast.error(err.message || '创建失败');
}
} catch { toast.error("创建组织失败") }
setCreating(false);
}
async function inviteMember() {
if (!inviteUserId.trim() || !selectedOrg) return;
setInviting(true);
try {
const res = await apiFetch(`/enterprise/organizations/${selectedOrg.id}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: parseInt(inviteUserId) }),
});
if (res.ok) {
setInviteUserId('');
selectOrg(selectedOrg);
} else {
const err = await res.json();
toast.error(err.message || '邀请失败');
}
} catch { toast.error("邀请失败") }
setInviting(false);
}
async function removeMember(userId: number) {
if (!selectedOrg || !confirm(t.enterprise.removeConfirm)) return;
try {
await apiFetch(`/enterprise/organizations/${selectedOrg.id}/members/${userId}`, {
method: 'DELETE',
});
selectOrg(selectedOrg);
} catch { toast.error("移除成员失败") }
}
if (!isLoggedIn) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<Building2 className="h-12 w-12 text-brand-600 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-foreground mb-2">{t.enterprise.dashboard}</h1>
<p className="text-muted-foreground mb-6">{t.enterprise.desc}</p>
<Button asChild><Link href="/auth">{t.common.login}</Link></Button>
</div>
);
}
if (loading) return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-40 rounded-2xl" />
</div>
);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground">{t.enterprise.dashboard}</h1>
<p className="text-muted-foreground mt-1">{t.enterprise.desc}</p>
</div>
<Button onClick={() => setShowCreateForm(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> {t.enterprise.createOrg}
</Button>
</div>
{showCreateForm && (
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-foreground">{t.enterprise.createOrg}</h2>
<button onClick={() => setShowCreateForm(false)}><X className="h-5 w-5 text-muted-foreground" /></button>
</div>
<div className="space-y-3">
<Input value={newOrgName} onChange={e => setNewOrgName(e.target.value)}
placeholder={t.enterprise.orgName} />
<Input value={newOrgDesc} onChange={e => setNewOrgDesc(e.target.value)}
placeholder={t.enterprise.orgDesc} />
<Button onClick={createOrg} disabled={creating || !newOrgName.trim()}>
{creating ? t.common.loading : t.enterprise.createOrg}
</Button>
</div>
</div>
)}
{orgs.length === 0 ? (
<div className="bg-card rounded-2xl border border-border p-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">{t.enterprise.noOrg}</h2>
<p className="text-sm text-muted-foreground mb-6">{t.enterprise.createOrgHint}</p>
<Button onClick={() => setShowCreateForm(true)}>{t.enterprise.createOrg}</Button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-3">
<h2 className="font-semibold text-foreground mb-2">{t.enterprise.myTeam}</h2>
{orgs.map(org => (
<button key={org.id} onClick={() => selectOrg(org)}
className={`w-full text-left bg-card rounded-xl border p-4 transition-all hover:shadow-sm ${
selectedOrg?.id === org.id ? 'border-brand-600' : 'border-border'
}`}>
<div className="font-medium text-foreground text-sm">{org.name}</div>
<div className="text-xs text-muted-foreground mt-1">
{t.enterprise.memberCount.replace('{n}', String(org.memberCount || org._count?.members || 0))}
</div>
</button>
))}
</div>
<div className="lg:col-span-2">
{selectedOrg ? (
<div>
<div className="flex items-center gap-2 mb-4">
<button onClick={() => setActiveTab('members')}
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
activeTab === 'members' ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border'
}`}>
<Users className="h-3.5 w-3.5 inline mr-1" />{t.enterprise.members}
</button>
<button onClick={() => setActiveTab('report')}
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
activeTab === 'report' ? 'bg-brand-600 text-white border-brand-600' : 'bg-card text-muted-foreground border-border'
}`}>
<BarChart3 className="h-3.5 w-3.5 inline mr-1" />{t.enterprise.usageReport}
</button>
</div>
{activeTab === 'members' && (
<div className="bg-card rounded-2xl border border-border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground">{t.enterprise.members} ({orgMembers.length})</h3>
</div>
<div className="flex gap-2 mb-4">
<Input value={inviteUserId} onChange={e => setInviteUserId(e.target.value)}
placeholder={t.enterprise.memberId} className="w-40" type="number" />
<Button onClick={inviteMember} disabled={inviting || !inviteUserId.trim()} size="sm">
{inviting ? t.common.loading : t.enterprise.invite}
</Button>
</div>
{orgMembers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">{t.enterprise.noMembers}</p>
) : (
<div className="space-y-2">
{orgMembers.map(m => (
<div key={m.id} className="flex items-center justify-between p-3 bg-muted/30 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center text-xs font-medium text-foreground">
{m.user.nickname?.[0] || '?'}
</div>
<div>
<div className="text-sm font-medium text-foreground">{m.user.nickname || `用户 ${m.userId}`}</div>
<div className="text-xs text-muted-foreground">
{m.user.email || m.user.phone || `#${m.userId}`}
{m.role === 'ADMIN' && <span className="ml-2 text-brand-600"></span>}
</div>
</div>
</div>
{m.role !== 'ADMIN' && (
<button onClick={() => removeMember(m.userId)}
className="text-xs text-red-500 hover:text-red-700"></button>
)}
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'report' && orgReport && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{[
{ label: t.enterprise.totalMembers, value: orgReport.totalMembers },
{ label: t.enterprise.totalUsage, value: orgReport.completedLessons },
{ label: t.enterprise.completionRate, value: `${Math.round(orgReport.completionRate)}%` },
].map((s, i) => (
<div key={i} className="bg-card rounded-xl border border-border p-4 text-center">
<div className="text-2xl font-bold text-foreground">{s.value}</div>
<div className="text-xs text-muted-foreground mt-1">{s.label}</div>
</div>
))}
</div>
{orgReport.memberProgress.length > 0 && (
<div className="bg-card rounded-2xl border border-border p-6">
<h3 className="font-semibold text-foreground mb-3">{t.enterprise.members} </h3>
<div className="space-y-3">
{orgReport.memberProgress.map((mp, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-sm text-foreground w-24 truncate">{mp.nickname || `用户 ${mp.userId}`}</span>
<div className="flex-1 bg-muted rounded-full h-2">
<div className="bg-brand-600 h-2 rounded-full" style={{ width: `${Math.min(100, mp.completed * 10)}%` }} />
</div>
<span className="text-xs text-muted-foreground">{mp.completed} </span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="bg-card rounded-2xl border border-border p-12 text-center">
<p className="text-muted-foreground">{t.enterprise.selectOrg || '选择一个团队查看详情'}</p>
</div>
)}
</div>
</div>
)}
<div className="mt-12 bg-card rounded-2xl border border-border p-8">
<div className="text-center max-w-2xl mx-auto">
<h2 className="text-xl font-bold text-foreground mb-3">{t.enterprise.pricingTitle}</h2>
<p className="text-muted-foreground mb-6">{t.enterprise.pricingDesc}</p>
<div className="flex items-center justify-center gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-foreground">{t.enterprise.pricingBizPrice}</div>
<div className="text-xs text-muted-foreground">{t.enterprise.pricingBizPerUser}</div>
</div>
<div className="h-12 w-px bg-border" />
<div className="text-left text-sm text-muted-foreground">
<div> {t.enterprise.pricingBizFeature1}</div>
<div> {t.enterprise.pricingBizFeature3}</div>
<div> {t.enterprise.pricingBizFeature4}</div>
</div>
</div>
</div>
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import Link from 'next/link';
import { useT } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Shield, Users, BarChart3, Cloud, ClipboardCheck, Target } from 'lucide-react';
const features = [
{ icon: Shield, key: 'featureIsolation' },
{ icon: Users, key: 'featureManage' },
{ icon: BarChart3, key: 'featureAnalytics' },
{ icon: Cloud, key: 'featureSandbox' },
{ icon: ClipboardCheck, key: 'featureReport' },
{ icon: Target, key: 'featureCustom' },
];
const faqs = ['faq1', 'faq2', 'faq3', 'faq4', 'faq5'];
export default function EnterprisePage() {
const t = useT();
return (
<div>
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 md:py-28">
<div className="text-center max-w-3xl mx-auto">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-600/10 border border-brand-200 dark:border-brand-800 rounded-full text-sm text-brand-600 mb-6">
<Shield className="h-4 w-4" />
{t.enterprise.title}
</div>
<h1 className="text-4xl md:text-5xl font-bold text-foreground leading-tight mb-6">
{t.enterprise.heroTitle}
</h1>
<p className="text-lg text-muted-foreground mb-8 leading-relaxed">
{t.enterprise.heroDesc}
</p>
<div className="flex items-center justify-center gap-4">
<Button size="lg" asChild>
<Link href="/enterprise/dashboard">{t.enterprise.heroCta}</Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link href="/practices">{t.enterprise.learnMore}</Link>
</Button>
</div>
<p className="text-xs text-muted-foreground mt-3">{t.enterprise.heroCtaSub}</p>
</div>
</section>
<section className="bg-muted/30 border-y border-border py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-foreground mb-3">{t.enterprise.problemTitle}</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">{t.enterprise.problemDesc}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ stat: 'statShadowAi', desc: 'statShadowAiDesc' },
{ stat: 'statDataLeak', desc: 'statDataLeakDesc' },
{ stat: 'statIpLeak', desc: 'statIpLeakDesc' },
{ stat: 'statTrainingGap', desc: 'statTrainingGapDesc' },
].map((s, i) => (
<div key={i} className="bg-card rounded-2xl border border-border p-6 text-center">
<div className="text-3xl font-bold text-brand-600 mb-1">{t.enterprise[s.stat as keyof typeof t.enterprise] as string}</div>
<div className="text-xs text-muted-foreground">{t.enterprise[s.desc as keyof typeof t.enterprise] as string}</div>
</div>
))}
</div>
</div>
</section>
<section className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-foreground mb-3">{t.enterprise.solutionTitle}</h2>
<p className="text-muted-foreground">{t.enterprise.solutionDesc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{features.map((feat, i) => {
const Icon = feat.icon;
return (
<div key={i} className="bg-card rounded-2xl border border-border p-6 hover:shadow-md transition-all">
<div className="w-10 h-10 bg-brand-600/10 rounded-xl flex items-center justify-center mb-4">
<Icon className="h-5 w-5 text-brand-600" />
</div>
<h3 className="font-semibold text-foreground mb-2">{t.enterprise[feat.key as keyof typeof t.enterprise]}</h3>
<p className="text-sm text-muted-foreground">{t.enterprise[(feat.key + 'Desc') as keyof typeof t.enterprise] as string}</p>
</div>
);
})}
</div>
</div>
</section>
<section className="bg-muted/30 border-y border-border py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-foreground mb-3">{t.enterprise.pricingTitle}</h2>
<p className="text-muted-foreground">{t.enterprise.pricingDesc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
<div className="bg-card rounded-2xl border border-border p-8">
<h3 className="text-lg font-semibold text-foreground mb-1">{t.enterprise.pricingFree}</h3>
<div className="text-3xl font-bold text-foreground mb-1">{t.enterprise.pricingFreePrice}</div>
<p className="text-sm text-muted-foreground mb-6">{t.enterprise.pricingFreeDesc}</p>
<ul className="space-y-2 mb-8">
<li className="text-sm text-muted-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingFreeFeature1}</li>
<li className="text-sm text-muted-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingFreeFeature2}</li>
<li className="text-sm text-muted-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingFreeFeature3}</li>
</ul>
<Button variant="outline" className="w-full" asChild>
<Link href="/practices">{t.enterprise.pricingCtaFree}</Link>
</Button>
</div>
<div className="bg-card rounded-2xl border-2 border-brand-600 p-8 relative">
<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>
<h3 className="text-lg font-semibold text-foreground mb-1">{t.enterprise.pricingBiz}</h3>
<div className="flex items-baseline gap-1 mb-1">
<span className="text-3xl font-bold text-foreground">{t.enterprise.pricingBizPrice}</span>
<span className="text-sm text-muted-foreground">{t.enterprise.pricingBizPerUser}</span>
</div>
<p className="text-sm text-muted-foreground mb-6">{t.enterprise.pricingBizDesc}</p>
<ul className="space-y-2 mb-8">
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature1}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature2}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature3}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature4}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature5}</li>
<li className="text-sm text-foreground flex items-center gap-2"><span className="text-green-500"></span> {t.enterprise.pricingBizFeature6}</li>
</ul>
<Button className="w-full" asChild>
<Link href="/enterprise/dashboard">{t.enterprise.pricingCta}</Link>
</Button>
</div>
</div>
</div>
</section>
<section className="py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-foreground text-center mb-12">{t.enterprise.faqTitle}</h2>
<div className="space-y-4">
{faqs.map((faq, i) => (
<details key={i} className="bg-card rounded-2xl border border-border p-4 group">
<summary className="text-sm font-medium text-foreground cursor-pointer list-none flex items-center justify-between">
{t.enterprise[(faq + 'q') as keyof typeof t.enterprise]}
<span className="text-muted-foreground group-open:rotate-180 transition-transform"></span>
</summary>
<p className="text-sm text-muted-foreground mt-3 pt-3 border-t border-border">{t.enterprise[(faq + 'a') as keyof typeof t.enterprise]}</p>
</details>
))}
</div>
</div>
</section>
<section className="bg-brand-600 py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-2xl font-bold text-white mb-4">{t.enterprise.heroTitle}</h2>
<p className="text-brand-100 mb-8">{t.enterprise.heroDesc}</p>
<Button size="lg" variant="secondary" asChild>
<Link href="/enterprise/dashboard">{t.enterprise.heroCta}</Link>
</Button>
</div>
</section>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
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 { API_BASE } from '@/lib/config';
import { ShoppingBag, CheckCircle, Lock, Sparkles } from 'lucide-react';
interface MarketplaceSkill {
id: string; name: string; description: string; icon: string;
category: string; difficulty: string; tags: string[];
price: number | null; purchased: boolean; sortOrder: number;
}
type FilterMode = 'all' | 'free' | 'premium' | 'purchased';
export default function MarketplacePage() {
const t = useT();
const router = useRouter();
const { isLoggedIn } = useAuth();
const [skills, setSkills] = useState<MarketplaceSkill[]>([]);
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(() => {
const url = `${API_BASE}/skills/marketplace`;
fetch(url, { credentials: 'include' })
.then(r => r.json())
.then(data => {
setSkills(Array.isArray(data) ? data : []);
})
.catch(() => {})
.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 (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.marketplace.title}</h1>
<p className="mt-2 text-muted-foreground">{t.marketplace.desc}</p>
</div>
<div className="flex flex-wrap gap-2 mb-8">
{filters.map(f => (
<button
key={f.key}
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>
)}
{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 => <Skeleton key={i} className="h-52 rounded-2xl" />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map(skill => {
const isPremium = !!skill.price;
const isOwned = skill.purchased;
return (
<div key={skill.id}
className={`bg-card rounded-2xl border-2 p-6 transition-all hover:shadow-md flex flex-col ${
isPremium && !isOwned ? 'border-amber-500/40' : 'border-border'
}`}
>
{isPremium && !isOwned && (
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 mb-2">
<Sparkles className="h-3.5 w-3.5" />
<span>{t.marketplace.locked}</span>
</div>
)}
<div className="flex items-start gap-3 mb-3">
<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>
)}
{!loading && filtered.length === 0 && (
<div className="text-center py-20">
<ShoppingBag className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">{t.common.noData}</p>
</div>
)}
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
onPaid={handlePaymentPaid}
/>
</div>
);
}
+33 -85
View File
@@ -1,29 +1,22 @@
'use client';
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { API_BASE } from '@/lib/config';
import Link from 'next/link';
import { MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag } from 'lucide-react';
import { useT } from '@/i18n';
interface AiModel { id: number; name: string; provider: string; description: string | null; capabilities: string | null; contextWindow: number | null; maxTokens: number | null; pricing: string | null; isFree: boolean; isFeatured: boolean; icon: string | null }
const icons = [MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag];
export default function ModelsPage() {
const t = useT();
const [models, setModels] = useState<AiModel[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/models`).then(r => r.json()).then(setModels).catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12"><Skeleton className="h-9 w-48 mx-auto mb-3" /><Skeleton className="h-5 w-96 mx-auto" /></div>
<div className="space-y-3">{[1,2,3,4,5].map(i => <Skeleton key={i} className="h-16 w-full rounded-xl" />)}</div>
</div>
);
}
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">
@@ -32,74 +25,29 @@ export default function ModelsPage() {
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.models.desc}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-4 px-4 font-semibold text-foreground">{t.models.tableName}</th>
<th className="text-left py-4 px-4 font-semibold text-foreground">{t.models.tableProvider}</th>
<th className="text-left py-4 px-4 font-semibold text-foreground hidden md:table-cell">{t.models.tableCapabilities}</th>
<th className="text-right py-4 px-4 font-semibold text-foreground hidden lg:table-cell">{t.models.tableContext}</th>
<th className="text-right py-4 px-4 font-semibold text-foreground hidden lg:table-cell">{t.models.tableMaxOutput}</th>
<th className="text-center py-4 px-4 font-semibold text-foreground hidden sm:table-cell">{t.models.tablePricing}</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr key={model.id} className="border-b border-border hover:bg-accent/50 transition-colors">
<td className="py-4 px-4">
<div className="font-semibold text-foreground">{model.name}</div>
{model.isFree && <span className="inline-block mt-1 text-xs px-1.5 py-0.5 bg-green-100 text-green-700 rounded">{t.models.free}</span>}
{model.isFeatured && !model.isFree && <span className="inline-block mt-1 text-xs px-1.5 py-0.5 bg-brand-100 text-brand-700 rounded">{t.models.recommended}</span>}
</td>
<td className="py-4 px-4 text-muted-foreground">{model.provider}</td>
<td className="py-4 px-4 text-muted-foreground hidden md:table-cell max-w-xs">
<div className="flex flex-wrap gap-1">
{model.capabilities?.split(',').map(cap => (
<span key={cap} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{cap.trim()}</span>
))}
</div>
</td>
<td className="py-4 px-4 text-right text-muted-foreground hidden lg:table-cell">
{model.contextWindow ? `${(model.contextWindow / 1000).toFixed(0)}K` : '-'}
</td>
<td className="py-4 px-4 text-right text-muted-foreground hidden lg:table-cell">
{model.maxTokens ? `${(model.maxTokens / 1024).toFixed(0)}K` : '-'}
</td>
<td className="py-4 px-4 text-center hidden sm:table-cell">
<span className={`text-xs px-2 py-1 rounded ${model.isFree ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'}`}>
{model.isFree ? t.models.pricingFree : model.pricing?.includes('免费') ? t.models.pricingMixed : t.models.pricingPaid}
</span>
</td>
</tr>
))}
</tbody>
</table>
<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>
{models.length > 0 && (
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{models.filter(m => m.isFeatured).map((model) => (
<div key={`card-${model.id}`} className="bg-card rounded-xl border border-border p-6 hover:border-brand-200 transition-colors">
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-foreground">{model.name}</h3>
{model.isFree && <span className="text-xs px-1.5 py-0.5 bg-green-100 text-green-700 rounded">{t.models.free}</span>}
</div>
<p className="text-sm text-muted-foreground mb-3">{model.provider}</p>
<p className="text-sm text-muted-foreground line-clamp-2">{model.description}</p>
<div className="mt-4 flex flex-wrap gap-1">
{model.capabilities?.split(',').slice(0, 4).map(cap => (
<span key={cap} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-600 rounded">{cap.trim()}</span>
))}
</div>
<div className="mt-4 pt-4 border-t border-border grid grid-cols-2 gap-3 text-xs text-muted-foreground">
<div><span className="block text-muted-foreground">{t.models.contextWindow}</span>{model.contextWindow ? `${(model.contextWindow / 1000).toFixed(0)}K tokens` : '-'}</div>
<div><span className="block text-muted-foreground">{t.models.maxOutput}</span>{model.maxTokens ? `${(model.maxTokens / 1024).toFixed(0)}K tokens` : '-'}</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
interface Stats {
totalCourses: number;
@@ -27,7 +28,7 @@ export default function DashboardPage() {
const res = await apiFetch('/dashboard/stats');
const data = await res.json();
setStats(data);
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
+3 -2
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface FavoritePrompt { id: number; promptId: number; prompt: { id: number; title: string; description: string; likeCount: number } }
@@ -16,12 +17,12 @@ export default function FavoritesPage() {
useEffect(() => { loadData(); }, []);
async function loadData() {
try { const res = await apiFetch('/prompts/favorites'); const data = await res.json(); setItems(data.items || []); } catch (e) { console.error(e) }
try { const res = await apiFetch('/prompts/favorites'); const data = await res.json(); setItems(data.items || []); } catch { /* ignore */ }
setLoading(false);
}
async function removeFavorite(promptId: number) {
try { await apiFetch(`/prompts/${promptId}/favorite`, { method: 'POST' }); setItems(items.filter(i => i.promptId !== promptId)); } catch (e) { console.error(e) }
try { await apiFetch(`/prompts/${promptId}/favorite`, { method: 'POST' }); setItems(items.filter(i => i.promptId !== promptId)); } catch { toast.error("操作失败") }
}
if (loading) return (
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
interface LearningItem {
courseId: number;
@@ -26,7 +27,7 @@ export default function LearningPage() {
const res = await apiFetch('/courses/my-learning');
const data = await res.json();
setItems(data.items || []);
} catch (e) { console.error(e) }
} catch { toast.error("加载失败") }
setLoading(false);
}
+10 -11
View File
@@ -6,6 +6,7 @@ import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { toast } from 'sonner';
import { isWeChatBrowser } from '@/lib/wechat';
interface PayResult {
@@ -26,10 +27,10 @@ interface Order {
}
const PLANS = [
{ id: 'FREE', nameKey: 'planFree' as const, price: 0, period: '', popular: false, features: ['featureSandboxFree', 'featureModelsFree', 'featurePromptsFree', 'featureCoursesFree', 'featureAdsFree'] as const },
{ id: 'MONTHLY', nameKey: 'planMonthly' as const, price: 49.9, period: 'perMonth', popular: true, features: ['featureSandboxPro', 'featureModelsPro', 'featurePromptsPro', 'featureCoursesPro', 'featureAdsPro'] as const },
{ id: 'YEARLY', nameKey: 'planYearly' as const, price: 299, period: 'perYear', popular: false, features: ['featureSandboxUnlimited', 'featureModelsPremium', 'featurePromptsPremium', 'featureCoursesPremium', 'featureAdsPremium'] as const },
];
{ 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;
@@ -54,12 +55,12 @@ export default function MemberPage() {
apiFetch('/orders'),
apiFetch('/sandbox/quota'),
]);
if (subRes.ok) setSubscription(await subRes.json());
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 (e) { console.error(e) }
} catch { toast.error("加载失败") }
setLoading(false);
}
@@ -82,9 +83,7 @@ export default function MemberPage() {
const payUrl = data.payResult.payUrl || data.payResult.redirectUrl;
const qrCode = data.payResult.qrcode || data.payResult.codeUrl;
if ((payUrl === 'mock://pay' || qrCode === 'mock://pay') || data.message === '模拟支付完成') {
loadData();
} else if (payUrl || qrCode) {
if (payUrl || qrCode) {
setPaymentModal({
open: true,
orderNo: data.order.orderNo,
@@ -93,7 +92,7 @@ export default function MemberPage() {
});
}
}
} catch (e) { console.error(e) }
} catch { toast.error("加载失败") }
setPayLoading(null);
}
@@ -146,7 +145,7 @@ export default function MemberPage() {
<h3 className="text-lg font-semibold text-foreground mb-1">{t.member[plan.nameKey]}</h3>
<div className="mb-4">
{plan.price > 0 ? (
<span className="text-3xl font-bold text-foreground">{plan.price === 49.9 ? t.member.priceMonthly : t.member.priceYearly}<span className="text-sm font-normal text-muted-foreground">{t.member[plan.period]}</span></span>
<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>
)}
+4 -3
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { apiFetch } from '../../../lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface Profile { nickname: string; email: string; phone: string; avatar: string }
@@ -24,7 +25,7 @@ export default function SettingsPage() {
const res = await apiFetch('/dashboard/profile');
const data = await res.json();
setProfile(data); setNickname(data.nickname || ''); setEmail(data.email || '');
} catch (e) { console.error(e) }
} catch { /* ignore */ }
setLoading(false);
}
@@ -33,8 +34,8 @@ export default function SettingsPage() {
setSaving(true);
try {
await apiFetch('/dashboard/profile', { method: 'PUT', body: JSON.stringify({ nickname, email }) });
alert(t.settings.saveSuccess);
} catch (e) { console.error(e) }
toast.success(t.settings.saveSuccess);
} catch { toast.error("保存失败") }
setSaving(false);
}
+4 -3
View File
@@ -6,6 +6,7 @@ import { apiFetch } from '@/lib/auth';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { toast } from 'sonner';
interface Notification { id: number; type: 'like' | 'comment' | 'follow' | 'system'; title: string; content?: string; link?: string; relatedId?: number; isRead: boolean; createdAt: string }
@@ -24,7 +25,7 @@ export default function NotificationsPage() {
try {
const res = await apiFetch('/notifications');
if (res.ok) { const data = await res.json(); setNotifications(data.items || []); setUnreadCount(data.unread || 0); }
} catch (e) { console.error(e) }
} catch { toast.error("加载通知失败") }
setLoading(false);
}, []);
@@ -35,7 +36,7 @@ export default function NotificationsPage() {
await apiFetch(`/notifications/${id}/read`, { method: 'PATCH' });
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
async function markAllRead() {
@@ -43,7 +44,7 @@ export default function NotificationsPage() {
await apiFetch('/notifications/read-all', { method: 'PATCH' });
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setUnreadCount(0);
} catch (e) { console.error(e) }
} catch { toast.error("操作失败") }
}
if (loading) return (
+49 -24
View File
@@ -3,25 +3,34 @@
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { HomePageClient } from './home-client';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap } from 'lucide-react';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap, Wrench } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
export default function HomePage() {
const t = useT();
const [courses, setCourses] = useState<any[]>([]);
const [featuredTools, setFeaturedTools] = useState<any[]>([]);
const [stats, setStats] = useState<{ courses: number; prompts: number; tools: number; users: number } | null>(null);
useEffect(() => {
fetch(`${API_BASE}/courses?pageSize=3`)
.then(r => r.json()).then(data => setCourses(data.items || []))
.catch(() => {});
Promise.all([
fetch(`${API_BASE}/courses?pageSize=3`).then(r => r.json()),
fetch(`${API_BASE}/public/stats`).then(r => r.json()),
fetch(`${API_BASE}/tools?isFeatured=true&pageSize=6`).then(r => r.json()),
]).then(([courseData, statsData, toolsData]) => {
setCourses(courseData.items || []);
setFeaturedTools(toolsData.items || []);
setStats(statsData);
}).catch(() => {});
}, []);
const stats = [
{ value: '50+', label: t.home.statTopics },
{ value: '200+', label: t.home.statPrompts },
{ value: '30+', label: t.home.statTools },
{ value: '10,000+', label: t.home.statExplorers },
const statItems = [
{ value: stats ? `${stats.courses}+` : '50+', label: t.home.statTopics },
{ value: stats ? `${stats.prompts}+` : '200+', label: t.home.statPrompts },
{ value: stats ? `${stats.tools}+` : '30+', label: t.home.statTools },
{ value: stats ? `${stats.users / 1000 > 1 ? Math.round(stats.users / 100) * 100 + '+' : stats.users + '+'}` : '10,000+', label: t.home.statExplorers },
];
const features = [
{ icon: Compass, title: t.home.featureGuide, desc: t.home.featureGuideDesc },
@@ -71,7 +80,7 @@ export default function HomePage() {
<section className="py-12 md:py-16 border-y border-border">
<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">
{stats.map((s) => (
{statItems.map((s) => (
<div key={s.label} className="text-center group">
<div className="text-3xl md:text-4xl font-bold bg-gradient-to-b from-brand-600 to-brand-400 bg-clip-text text-transparent group-hover:scale-110 transition-transform">
{s.value}
@@ -83,6 +92,35 @@ export default function HomePage() {
</div>
</section>
{/* Featured Tools */}
{featuredTools.length > 0 && (
<section className="py-16 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold">{t.home.featuredToolsTitle}</h2>
<p className="mt-2 text-muted-foreground">{t.home.featuredToolsDesc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{featuredTools.map((tool: any) => (
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer" className="block group">
<Card className="p-4 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
<Wrench className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold group-hover:text-brand-600 transition-colors truncate">{tool.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-1">{tool.description}</p>
</div>
</div>
</Card>
</a>
))}
</div>
</div>
</section>
)}
{/* Features */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -131,20 +169,7 @@ export default function HomePage() {
</div>
</Link>
)) : (
<>
{[{ title: 'AI 通识:零基础入门', tag: t.courses.free }, { title: '提示词工程从入门到精通', tag: '热门' }, { title: '用 AI 提升 10 倍办公效率', tag: '推荐' }].map((course) => (
<div key={course.title} className="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-xl transition-all hover:-translate-y-1">
<div className="h-2 bg-gradient-to-r from-brand-500 to-blue-500" />
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 px-2 py-1 rounded-full">{course.tag}</span>
</div>
<h3 className="text-lg font-semibold mb-2 group-hover:text-brand-600 transition-colors">{course.title}</h3>
<p className="text-sm text-muted-foreground mb-4 line-clamp-2"> AI </p>
</div>
</div>
))}
</>
<div className="col-span-3 text-center py-12 text-muted-foreground">{t.common.loading}</div>
)}
</div>
</div>
+251
View File
@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { useAuth } from '@/lib/auth-context';
import { apiFetch, getToken } from '@/lib/auth';
import { Lightbulb, CheckCircle, Clock, BarChart3 } from 'lucide-react';
interface PracticeQuestion {
id: number;
skillId: string | null;
scenario: string;
title: string;
description: string;
instructions: string;
difficulty: string;
category: string;
expectedCriteria: string;
starterCode: string | null;
hint: string | null;
}
interface CriteriaScore {
name: string;
score: number;
maxScore: number;
reason: string;
}
interface Submission {
id: number;
questionId: number;
answer: string;
score: number | null;
maxScore: number;
feedback: string | null;
criteriaScores: string | null;
duration: number;
status: string;
submittedAt: string;
scoredAt: string | null;
question: PracticeQuestion;
}
export default function PracticeDetailPage() {
const params = useParams();
const t = useT();
const { isLoggedIn } = useAuth();
const [question, setQuestion] = useState<PracticeQuestion | null>(null);
const [loading, setLoading] = useState(true);
const [answer, setAnswer] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submission, setSubmission] = useState<Submission | null>(null);
const [showHint, setShowHint] = useState(false);
const [startTime] = useState(Date.now());
useEffect(() => {
if (!params.id) return;
fetch(`${API_BASE}/practices/${params.id}`)
.then(r => r.json())
.then(data => {
if (data.id) setQuestion(data);
})
.finally(() => setLoading(false));
}, [params.id]);
useEffect(() => {
if (!isLoggedIn || !params.id) return;
apiFetch(`/practices/submissions?limit=50`)
.then(r => r.json())
.then(data => {
const found = (data.items || []).find((s: any) => s.questionId === Number(params.id));
if (found) setSubmission(found);
})
.catch(() => {});
}, [isLoggedIn, params.id]);
const handleSubmit = async () => {
if (!answer.trim() || !isLoggedIn) return;
setSubmitting(true);
try {
const duration = Math.floor((Date.now() - startTime) / 1000);
const res = await apiFetch(`/practices/${params.id}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: answer.trim(), duration }),
});
if (res.ok) {
const data = await res.json();
setSubmission(data);
} else {
const err = await res.json();
alert(err.message || '提交失败');
}
} catch (err: any) {
alert(err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
const difficultyColor = question?.difficulty === 'BEGINNER' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: question?.difficulty === 'INTERMEDIATE' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
if (loading) return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-64 rounded-2xl mb-6" />
<Skeleton className="h-48 rounded-2xl" />
</div>
);
if (!question) return (
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
<p className="text-muted-foreground"></p>
<Link href="/practices" className="text-brand-600 hover:underline text-sm mt-4 inline-block">&larr; {t.practices.title}</Link>
</div>
);
const parsedCriteria: CriteriaScore[] = submission?.criteriaScores ? JSON.parse(submission.criteriaScores) : [];
return (
<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">
&larr; {t.practices.title}
</Link>
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${difficultyColor}`}>
{(t.practices as any)[question.difficulty.toLowerCase()] || question.difficulty}
</span>
<span className="text-xs text-muted-foreground">{(t.practices.categories as any)[question.category] || question.category}</span>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">{question.title}</h1>
<p className="text-muted-foreground mb-4">{question.description}</p>
<div className="bg-muted/50 rounded-xl p-4 border border-border">
<h3 className="text-sm font-medium text-foreground mb-2">{t.practices.title} </h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{question.scenario}</p>
</div>
</div>
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
<h2 className="font-semibold text-foreground mb-3">{t.practices.title} </h2>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{question.instructions}</p>
{question.hint && (
<div className="mt-3">
<button onClick={() => setShowHint(!showHint)}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700">
<Lightbulb className="h-4 w-4" />
{t.practices.hint}
</button>
{showHint && <p className="mt-2 text-sm text-muted-foreground bg-muted/50 rounded-xl p-3">{question.hint}</p>}
</div>
)}
</div>
{submission?.status === 'SCORED' ? (
<div className="space-y-6">
<div className="bg-card rounded-2xl border border-border p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-foreground">{t.practices.score}</h2>
<div className="flex items-center gap-2">
<span className="text-3xl font-bold text-brand-600">{submission.score}</span>
<span className="text-muted-foreground">/ {submission.maxScore}</span>
</div>
</div>
<div className="space-y-3 mb-6">
{parsedCriteria.map((c, i) => (
<div key={i} className="bg-muted/50 rounded-xl p-4">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-foreground">{c.name}</span>
<span className="text-sm text-muted-foreground">{c.score}/{c.maxScore}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 mb-1">
<div className="bg-brand-600 h-2 rounded-full transition-all" style={{ width: `${(c.score / c.maxScore) * 100}%` }} />
</div>
<p className="text-xs text-muted-foreground">{c.reason}</p>
</div>
))}
</div>
{submission.feedback && (
<div className="bg-muted/50 rounded-xl p-4 border border-border">
<h3 className="text-sm font-medium text-foreground mb-2">{t.practices.feedback}</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{submission.feedback}</p>
</div>
)}
</div>
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="font-semibold text-foreground mb-3">{t.practices.yourAnswer}</h2>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{submission.answer}</p>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t.practices.duration}: {submission.duration}{t.practices.seconds}</span>
</div>
</div>
) : (
<div className="space-y-4">
{submission?.status === 'SUBMITTED' ? (
<div className="bg-card rounded-2xl border border-border p-6 text-center">
<Clock className="h-8 w-8 text-yellow-500 mx-auto mb-2" />
<p className="text-muted-foreground">AI ...</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => window.location.reload()}>
</Button>
</div>
) : (
<>
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="font-semibold text-foreground mb-3">{t.practices.yourAnswer}</h2>
<textarea value={answer} onChange={e => setAnswer(e.target.value)}
placeholder={t.practices.answerPlaceholder}
className="w-full min-h-[200px] p-4 border border-border rounded-xl bg-background text-foreground text-sm resize-y focus:outline-none focus:ring-2 focus:ring-brand-600"
/>
</div>
{isLoggedIn ? (
<Button onClick={handleSubmit} disabled={submitting || !answer.trim()}
className="w-full sm:w-auto">
{submitting ? (
<span className="flex items-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full" />
{t.practices.submitting}
</span>
) : t.practices.submit}
</Button>
) : (
<div className="bg-muted/50 rounded-xl p-4 text-center border border-border">
<p className="text-muted-foreground mb-3">{t.practices.loginRequired}</p>
<Button asChild>
<Link href="/auth">{t.common.login}</Link>
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import PracticeDetailClient from './client';
export function generateStaticParams() {
const ids = Array.from({ length: 20 }, (_, i) => i + 1);
return ids.map(id => ({ id: String(id) }));
}
export default function PracticeDetailPage() {
return <PracticeDetailClient />;
}
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import PaymentModal from '@/components/ui/payment-modal';
import { useT } from '@/i18n';
import { Zap, CheckCircle } from 'lucide-react';
interface Quota {
dailyLimit: number;
used: number;
dailyRemaining: number;
extra: 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() {
const t = useT();
const { isLoggedIn } = useAuth();
const [quota, setQuota] = useState<Quota | null>(null);
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(() => {
if (!isLoggedIn) { setLoading(false); return; }
apiFetch('/sandbox/quota')
.then(r => r.json())
.then(data => setQuota(data))
.catch(() => {})
.finally(() => setLoading(false));
}, [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) {
return (
<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" />
<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>
<Button asChild><Link href="/auth">{t.common.login}</Link></Button>
</div>
);
}
return (
<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">
&larr; {t.practices.title}
</Link>
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.packages.title}</h1>
<p className="mt-2 text-muted-foreground">{t.packages.desc}</p>
</div>
{quota && (
<div className="bg-card rounded-2xl border border-border p-6 mb-8">
<h2 className="font-semibold text-foreground mb-3">{t.packages.currentQuota}</h2>
<div className="flex flex-wrap gap-6">
<div>
<span className="text-sm text-muted-foreground">{t.packages.dailyQuota.replace('{used}', String(quota.used)).replace('{limit}', String(quota.dailyLimit))}</span>
</div>
<div>
<span className="text-sm text-muted-foreground">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>
</div>
</div>
</div>
)}
{success && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6 flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
<span className="text-sm text-green-800 dark:text-green-300">{success}</span>
</div>
)}
<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>
) : t.packages.buy}
</Button>
</div>
))}
</div>
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
{t.packages.orUpgrade}{' '}
<Link href="/my/member" className="text-brand-600 hover:underline">{t.member.title}</Link>
</p>
</div>
<PaymentModal
open={paymentModal.open}
orderNo={paymentModal.orderNo}
payResult={paymentModal.payResult}
payChannel={paymentModal.payChannel}
onClose={() => setPaymentModal(prev => ({ ...prev, open: false }))}
onPaid={handlePaymentPaid}
/>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { useAuth } from '@/lib/auth-context';
import { apiFetch } from '@/lib/auth';
interface Practice {
id: number;
title: string;
scenario: string;
description: string;
difficulty: string;
category: string;
attemptCount: number;
sortOrder: number;
createdAt: string;
}
interface Submission {
id: number;
questionId: number;
score: number | null;
status: string;
submittedAt: string;
}
const difficultyColors: Record<string, string> = {
BEGINNER: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
INTERMEDIATE: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
ADVANCED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
};
export default function PracticesPage() {
const t = useT();
const { isLoggedIn } = useAuth();
const [practices, setPractices] = useState<Practice[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [category, setCategory] = useState('');
const [difficulty, setDifficulty] = useState('');
const [search, setSearch] = useState('');
const [submissions, setSubmissions] = useState<Record<number, Submission>>({});
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/practices`).then(r => r.json()),
fetch(`${API_BASE}/practices/categories`).then(r => r.json()),
]).then(([data, cats]) => {
setPractices(data.items || []);
setCategories(cats || []);
}).finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!isLoggedIn) return;
apiFetch('/practices/submissions?limit=100')
.then(r => r.json())
.then(data => {
const map: Record<number, Submission> = {};
(data.items || []).forEach((s: Submission) => { map[s.questionId] = s; });
setSubmissions(map);
})
.catch(() => {});
}, [isLoggedIn]);
useEffect(() => {
const params = new URLSearchParams();
if (category) params.set('category', category);
if (difficulty) params.set('difficulty', difficulty);
if (search) params.set('search', search);
fetch(`${API_BASE}/practices?${params}`)
.then(r => r.json())
.then(data => setPractices(data.items || []));
}, [category, difficulty, search]);
const difficulties = [
{ id: 'BEGINNER', name: t.practices.beginner },
{ id: 'INTERMEDIATE', name: t.practices.intermediate },
{ id: 'ADVANCED', name: t.practices.advanced },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.practices.title}</h1>
<p className="mt-2 text-muted-foreground">{t.practices.desc}</p>
</div>
<div className="flex flex-wrap gap-3 mb-8">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder={t.common.search}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground w-48" />
<select value={category} onChange={e => setCategory(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.practices.allCategories}</option>
{categories.map(c => <option key={c} value={c}>{(t.practices.categories as any)[c] || c}</option>)}
</select>
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.practices.allDifficulties}</option>
{difficulties.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
{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 => <Skeleton key={i} className="h-44 rounded-2xl" />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{practices.map(p => {
const sub = submissions[p.id];
return (
<Link key={p.id} href={`/practices/${p.id}`}
className="bg-card rounded-2xl border border-border p-6 hover:shadow-md transition-all hover:-translate-y-0.5 group">
<div className="mb-2 flex items-start justify-between gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${difficultyColors[p.difficulty] || ''}`}>
{(t.practices as any)[p.difficulty.toLowerCase()] || p.difficulty}
</span>
{sub && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
sub.status === 'SCORED' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}>
{sub.status === 'SCORED' ? t.practices.scoreRange.replace('{score}', String(sub.score)).replace('{max}', '100') : (t.practices.status as any)[sub.status]}
</span>
)}
</div>
<h3 className="font-semibold text-foreground group-hover:text-brand-600 transition-colors mb-1">{p.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{p.description}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{(t.practices.categories as any)[p.category] || p.category}</span>
<span>·</span>
<span>{p.attemptCount} </span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
+8 -4
View File
@@ -42,7 +42,9 @@ export default function CodeSandboxPage() {
try {
setCode(atob(initialCode));
setTemplate('');
} catch {}
} catch (err) {
console.error('Failed to decode code param:', err);
}
}
}, []);
@@ -66,7 +68,7 @@ export default function CodeSandboxPage() {
try {
const iframe = iframeRef.current;
if (!iframe || !iframe.contentWindow) return;
const win = iframe.contentWindow;
const win = iframe.contentWindow as unknown as { console: Console };
const origLog = win.console.log;
const origError = win.console.error;
const newLogs: string[] = [];
@@ -82,7 +84,9 @@ export default function CodeSandboxPage() {
setTimeout(() => {
setLogs(newLogs);
}, 500);
} catch {}
} catch (err) {
console.error('Failed to setup iframe console:', err);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
@@ -137,7 +141,7 @@ export default function CodeSandboxPage() {
onLoad={handleIframeLoad}
className="w-full h-full border-0"
title="预览"
sandbox="allow-scripts allow-modals allow-same-origin"
sandbox="allow-scripts allow-modals"
/>
</div>
{logs.length > 0 && (
+1 -1
View File
@@ -19,7 +19,7 @@ export default function ComparePage() {
const router = useRouter();
const [prompt, setPrompt] = useState('');
const [results, setResults] = useState<ModelResult[]>(
MODELS.map(m => ({ ...m, reply: '', loading: false }))
MODELS.map(m => ({ model: m.id, label: m.label, reply: '', loading: false }))
);
const [sending, setSending] = useState(false);
const [showParams, setShowParams] = useState(false);
+12 -7
View File
@@ -104,7 +104,7 @@ function SandboxPage() {
const [temperature, setTemperature] = useState(0.7);
const [topP, setTopP] = useState(1);
const [maxTokens, setMaxTokens] = useState(2000);
const [quota, setQuota] = useState<{ used: number; remaining: number } | null>(null);
const [quota, setQuota] = useState<{ used: number; dailyLimit: number; dailyRemaining: number; extra: number; totalRemaining: number; remaining: number } | null>(null);
const [sessions, setSessions] = useState<SessionItem[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsOpen, setSessionsOpen] = useState(false);
@@ -144,6 +144,7 @@ function SandboxPage() {
{ id: 'coding', name: '编程助手', icon: '💻', systemPrompt: '你是一个编程专家,擅长解答编程问题和编写代码', starters: ['用 Python 写一个斐波那契数列', '解释 RESTful API 设计原则', '帮我调试这段代码', '什么是闭包?'] },
{ id: 'writing', name: '写作助手', icon: '✍️', systemPrompt: '你是一个专业的写作助手,擅长润色和创作各类文本', starters: ['帮我润色这段文字', '写一篇产品介绍', '如何写好工作总结?', '帮我拟一份会议邀请'] },
{ id: 'study', name: '学习辅导', icon: '📚', systemPrompt: '你是一个耐心的学习辅导员,善于解释复杂概念', starters: ['什么是机器学习?', '解释 HTTP 与 HTTPS 的区别', '帮我理解微积分', '英语单词记忆技巧'] },
{ id: 'tutor', name: '编程导师', icon: '🧑‍🏫', systemPrompt: '你是一位编程导师,采用苏格拉底式教学法。核心理念是「不直接给答案,引导对方自己找到答案」。当学习者提出编程问题时:1) 先通过提问理解他们的水平和困惑点;2) 给出思路提示而非完整代码;3) 如果对方卡住了,逐步增加提示的明确度;4) 只有在对方已经尝试并仍然困惑时,才给出带解释的参考代码。目标是培养独立思考和问题解决能力。', starters: ['我想学编程,从哪开始?', '变量和常量有什么区别?', '这段代码为啥报错?帮我看下', 'for 循环和 while 循环有什么区别?'] },
];
fetch(`${API_BASE}/skills`)
@@ -772,22 +773,26 @@ function SandboxPage() {
</div>
<div className="border-t border-border p-4">
{quota && quota.remaining <= 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="flex items-center justify-between">
<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.sandbox.quotaUpgradeHint}</div>
<div className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">{t.packages.buyMore}</div>
</div>
<Link href="/my/member"
<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.sandbox.upgradeNow}
{t.packages.title}
</Link>
</div>
</div>
) : quota && (
<div className="text-xs text-muted-foreground mb-2">
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
<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>
{quota.extra > 0 && <span className="text-brand-600">{t.packages.extraQuota.replace('{n}', String(quota.extra))}</span>}
{quota.dailyRemaining <= 0 && quota.extra > 0 && (
<Link href="/practices/packages" className="text-brand-600 hover:underline text-[10px]">{t.packages.buyMore}</Link>
)}
</div>
)}
{uploadedImages.length > 0 && (
+2 -1
View File
@@ -8,7 +8,7 @@ import { Wrench, ExternalLink, Star } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useT } from '@/i18n';
interface Tool { id: number; name: string; description: string; url: string; icon: string | null; isFeatured: boolean; tags: string | null }
interface Tool { id: number; name: string; description: string; url: string; icon: string | null; affiliateLink: string | null; isFeatured: boolean; tags: string | null }
function ToolSkeleton() {
return (
@@ -51,6 +51,7 @@ export default function ToolsPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold group-hover:text-brand-600 transition-colors">{tool.name}</h3>
{tool.affiliateLink && <Badge className="bg-green-600 hover:bg-green-700 text-white text-[10px] px-1.5 py-0"></Badge>}
{tool.isFeatured && <Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{tool.description}</p>
+8 -6
View File
@@ -4,7 +4,9 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { getToken } from '@/lib/auth';
import { API_BASE } from '@/lib/config';
import { toast } from 'sonner';
interface UserProfile {
id: number; nickname: string; avatar?: string; bio?: string;
@@ -30,10 +32,10 @@ export default function UserProfilePage() {
useEffect(() => { loadData(); }, [userId]);
function getToken() { return localStorage.getItem('token'); }
function getAuthToken() { return getToken(); }
function apiHeaders() {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
const t = getToken();
const t = getAuthToken();
if (t) h['Authorization'] = `Bearer ${t}`;
return h;
}
@@ -60,7 +62,7 @@ export default function UserProfilePage() {
setIsFollowing(d.followed);
}
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
async function toggleFollow() {
@@ -78,7 +80,7 @@ export default function UserProfilePage() {
followerCount: prev.followerCount + (isFollowing ? -1 : 1),
} : prev);
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
async function loadFollowers() {
@@ -88,7 +90,7 @@ export default function UserProfilePage() {
const d = await res.json();
setFollowers(d.items || []);
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
async function loadFollowing() {
@@ -98,7 +100,7 @@ export default function UserProfilePage() {
const d = await res.json();
setFollowing(d.items || []);
}
} catch (e) { console.error(e) }
} catch { /* ignore */ }
}
useEffect(() => {
+84 -12
View File
@@ -1,12 +1,12 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Search, Menu, X, Bell, Languages } from 'lucide-react';
import { Search, Menu, X, Bell, Languages, ChevronDown } from 'lucide-react';
import { apiFetch } from '@/lib/auth';
import { useAuth } from '@/lib/auth-context';
import { useLang, useT } from '@/i18n';
@@ -53,18 +53,35 @@ export function Header() {
const { lang, setLang } = useLang();
const t = useT();
const navItems = [
const mainNavItems = [
{ href: '/', label: t.nav.home },
{ href: '/courses', label: t.nav.courses },
{ href: '/marketplace', label: t.nav.marketplace },
{ href: '/sandbox', label: t.nav.sandbox },
{ href: '/skills', label: t.nav.skills },
{ href: '/models', label: t.nav.models },
{ href: '/prompts', label: t.nav.prompts },
{ href: '/contents', label: t.nav.articles },
{ href: '/tools', label: t.nav.tools },
{ href: '/community', label: t.nav.community },
{ href: '/practices', label: t.nav.practices },
// { href: '/community', label: t.nav.community },
// { href: '/enterprise', label: t.nav.enterprise },
];
const secondaryNavItems = [
{ href: '/courses', label: t.nav.courses },
{ href: '/prompts', label: t.nav.prompts },
{ href: '/models', label: t.nav.models },
{ href: '/tools', label: t.nav.tools },
{ href: '/contents', label: t.nav.articles },
];
const [moreOpen, setMoreOpen] = useState(false);
const moreTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function openMore() {
if (moreTimer.current) clearTimeout(moreTimer.current);
setMoreOpen(true);
}
function closeMore() {
moreTimer.current = setTimeout(() => setMoreOpen(false), 200);
}
function isActive(href: string) {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
@@ -92,7 +109,7 @@ export function Header() {
</Link>
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
@@ -105,6 +122,46 @@ export function Header() {
{item.label}
</Link>
))}
<div
className="relative"
onMouseEnter={openMore}
onMouseLeave={closeMore}
>
<button
className={`flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg transition-all ${
moreOpen
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
{t.nav.more}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${moreOpen ? 'rotate-180' : ''}`} />
</button>
<div
className="absolute right-0 top-full z-50 pt-1"
onMouseEnter={openMore}
onMouseLeave={closeMore}
>
{moreOpen && (
<div className="w-40 bg-popover border border-border rounded-lg shadow-lg py-1 animate-fade-in">
{secondaryNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block px-3 py-2 text-sm transition-colors ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
onClick={() => setMoreOpen(false)}
>
{item.label}
</Link>
))}
</div>
)}
</div>
</div>
</nav>
<div className="hidden md:flex items-center gap-2">
@@ -170,7 +227,22 @@ export function Header() {
{mobileOpen && (
<nav className="md:hidden pb-4 border-t border-border pt-4 animate-fade-in">
{navItems.map((item) => (
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`block py-2.5 px-2 text-sm rounded-lg transition-colors ${
isActive(item.href)
? 'bg-accent text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
))}
<div className="border-t border-border my-2 mx-2" />
{secondaryNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
@@ -1,7 +1,7 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
import { type ThemeProviderProps } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
+5 -2
View File
@@ -1,6 +1,7 @@
'use client'
import { useRef, useState } from 'react'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import python from 'highlight.js/lib/languages/python'
@@ -48,7 +49,9 @@ export function CodeBlock({ code, language }: CodeBlockProps) {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {}
} catch (err) {
console.error('Copy failed:', err)
}
}
return (
@@ -61,7 +64,7 @@ export function CodeBlock({ code, language }: CodeBlockProps) {
</button>
</div>
<pre className="overflow-x-auto p-4 text-sm leading-relaxed bg-[#1e1e1e]">
<code ref={codeRef} className={`language-${language || ''}`} dangerouslySetInnerHTML={{ __html: highlighted }} />
<code ref={codeRef} className={`language-${language || ''}`} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(highlighted) }} />
</pre>
</div>
)
+1 -1
View File
@@ -44,7 +44,7 @@ export default function PaymentModal({ open, orderNo, payResult, payChannel, onP
if (payChannel === 'alipay') {
setMessage(t.member.alipayRedirect);
if (redirectUrl && redirectUrl !== 'mock://pay') {
if (redirectUrl) {
window.open(redirectUrl, '_blank');
}
startPolling();
+13 -8
View File
@@ -2,10 +2,10 @@ import type { Translations } from './zh'
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' },
nav: { home: 'Home', courses: 'Courses', prompts: 'Prompts', sandbox: 'Sandbox', discover: 'Discover', my: 'My', tools: 'Tools', community: 'Community', skills: 'Skills', models: 'Models', articles: 'Articles' },
home: { badge: 'Free AI Learning Community', heroHighlight: 'Empower Everyone', heroRest: 'to Master AI', desc: 'AI knowledge, prompt engineering, sandbox practice & model encyclopedia', 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: 'Guided Learning', featureGuideDesc: 'Content organized by role and scenario', featureSandbox: 'AI Sandbox', featureSandboxDesc: 'Built-in AI sandbox to learn by doing', 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', ctaTitle: 'Ready to Start Your AI Journey?', ctaDesc: 'Register now and explore everything for free' },
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' },
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' },
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', 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' },
community: { title: 'Community', desc: 'Share AI learning experiences with others', createPost: '+ New Post', latest: 'Latest', following: 'Following', newPost: 'New Post', postTitle: 'Title', contentPlaceholder: 'Share your AI learning experience...', tagsPlaceholder: 'Tags (comma-separated)', publishing: 'Publishing...', publish: 'Publish', feedEmpty: 'Follow more users to discover great content', postsEmpty: 'No posts yet — be the first!', commentPlaceholder: 'Write a comment...', sending: 'Sending...', comment: 'Comment', followed: 'Following', follow: '+ Follow' },
notifications: { title: 'Notifications', unreadCount: 'You have {n} unread notifications', noUnread: 'No unread notifications', markAllRead: 'Mark all read', markRead: 'Read', emptyTitle: 'No notifications', emptyDesc: 'Likes, comments and follows will appear here' },
my: { desc: 'Manage your profile and favorites', learningProgress: 'Learning Progress', learningProgressDesc: 'View your course progress', favorites: 'My Favorites', favoritesDesc: 'Saved prompts and courses', memberCenter: 'Membership', memberDesc: 'Manage subscription and benefits', settings: 'Settings', settingsDesc: 'Account settings and preferences', analyticsDesc: 'Knowledge mastery analysis based on conversations', pathDesc: 'Master AI skills systematically' },
@@ -15,22 +15,27 @@ const en: Translations = {
courses: { title: 'Courses', desc: 'Explore AI systematically from beginner to expert', empty: 'No courses available', free: 'Free', paid: 'Paid', moduleCount: '{n} modules' },
search: { title: 'Search Results', placeholder: 'Search courses, prompts, tools, articles...', emptyHint: 'Enter keywords to search', noResults: 'No results found for "{q}"', resultsCount: '{n} results found', groupCourse: 'Courses', groupPrompt: 'Prompts', groupTool: 'AI Tools', groupContent: 'Articles' },
tools: { title: 'AI Tools', desc: 'Curated AI tools to boost your productivity' },
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}' },
discover: { desc: 'Explore trending content and curated picks', hotCourses: 'Hot Courses', hotPrompts: 'Trending Prompts', hotPosts: 'Popular Discussions', viewCount: '{n} views', likeCount: '{n} likes', postStats: '❤️ {likes} · 👁 {views}', toolsTitle: 'AI Tool Picks', toolsDesc: 'Curated popular AI tools' },
circles: { title: 'Circles', desc: 'Topic-based discussion groups', empty: 'No circles yet', back: 'Back to Discover', members: 'members', posts: 'posts' },
brand: { name: 'Yuzhiran', suffix: 'AI' },
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: 'Models', aiTools: 'AI Tools', articles: 'Articles', skills: 'Skills' },
models: { title: 'AI Model Encyclopedia', desc: 'Compare mainstream LLMs to find the best fit', tableName: 'Model', tableProvider: 'Provider', tableCapabilities: 'Capabilities', tableContext: 'Context', tableMaxOutput: 'Max Output', tablePricing: 'Pricing', free: 'Free', recommended: 'Recommended', pricingFree: 'Free', pricingMixed: 'Free/Paid', pricingPaid: 'Paid', contextWindow: 'Context', maxOutput: 'Max Output' },
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' },
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' },
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' },
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', 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' },
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...' },
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' },
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' } },
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' },
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' } },
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' },
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.' },
}
+13 -8
View File
@@ -1,9 +1,9 @@
const zh = {
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: '文章' },
home: { badge: '免费 AI 知识社区', heroHighlight: '让每个人', heroRest: '都能用好 AI', desc: '涵盖 AI 通识、提示词工程、沙盒实战、模型百科', startExplore: '开始探索', freeRegister: '免费注册', statTopics: 'AI 专题', statPrompts: '精选提示词', statTools: 'AI 工具评测', statExplorers: '探索者', whyTitle: '为什么选择宇之然?', whyDesc: '四大核心优势,助你快速掌握 AI', featureGuide: '分领域指南', featureGuideDesc: '按职业和场景分类内容,学即所用', featureSandbox: 'AI 沙盒实战', featureSandboxDesc: '内置 AI 对话沙盒,边学边练', featurePrompts: '提示词库', featurePromptsDesc: '精选 200+ 提示词模板', featureUpdate: '持续更新', featureUpdateDesc: '紧跟大模型迭代,内容实时更新', popularTopics: '热门专题', popularDesc: '从入门到精通,系统探索 AI', moduleCount: '{n} 模块', studentCount: '{n} 人关注', openSandbox: '打开沙盒', ctaTitle: '准备好开启 AI 之旅了吗?', ctaDesc: '立即注册,免费探索所有内容' },
nav: { home: '首页', courses: '课程', prompts: '提示词库', sandbox: '沙盒', discover: '发现', my: '我的', tools: '工具', community: '社区', skills: '技能', models: '模型', articles: '文章', practices: '练习', enterprise: '企业版', marketplace: '技能广场', 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: '立即注册,免费探索所有内容' },
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: '个人设置', 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: '加载数据失败' },
community: { title: '社区', desc: '与 AI 学习者交流心得', createPost: '+ 发帖', latest: '最新', following: '关注', newPost: '发布新帖', postTitle: '标题', contentPlaceholder: '分享你的 AI 学习心得、实战经验...', tagsPlaceholder: '标签(逗号分隔)', publishing: '发布中...', publish: '发布', feedEmpty: '关注更多用户,发现精彩内容', postsEmpty: '还没有帖子,来发第一帖吧!', commentPlaceholder: '写下你的评论...', sending: '发送中...', comment: '评论', followed: '已关注', follow: '+ 关注' },
notifications: { title: '通知', unreadCount: '你有 {n} 条未读通知', noUnread: '暂无未读通知', markAllRead: '全部已读', markRead: '已读', emptyTitle: '暂无通知', emptyDesc: '点赞、评论或关注你的人会出现在这里' },
my: { desc: '管理你的个人信息和收藏', learningProgress: '学习进度', learningProgressDesc: '查看你的课程学习进度', favorites: '我的收藏', favoritesDesc: '提示词、课程等收藏内容', memberCenter: '会员中心', memberDesc: '管理会员订阅和权益', settings: '设置', settingsDesc: '账号设置和安全偏好', analyticsDesc: '基于对话的知识掌握度分析', pathDesc: '分阶段系统掌握 AI 技能' },
@@ -13,22 +13,27 @@ const zh = {
courses: { title: '专题', desc: '系统化探索 AI,从入门到精通', empty: '暂无专题内容', free: '免费', paid: '付费', moduleCount: '{n} 模块' },
search: { title: '搜索结果', placeholder: '搜索专题、提示词、工具、文章...', emptyHint: '输入关键词搜索', noResults: '未找到与 "{q}" 相关的结果', resultsCount: '找到 {n} 个结果', groupCourse: '专题', groupPrompt: '提示词', groupTool: 'AI 工具', groupContent: '文章' },
tools: { title: 'AI 工具库', desc: '收录优质 AI 工具,助力工作效率提升' },
discover: { desc: '探索热门内容和精选推荐', hotCourses: '热门课程', hotPrompts: '热门提示词', hotPosts: '热门讨论', viewCount: '{n} 浏览', likeCount: '{n} 点赞', postStats: '❤️ {likes} 点赞 · 👁 {views} 浏览' },
discover: { desc: '探索热门内容和精选推荐', hotCourses: '热门课程', hotPrompts: '热门提示词', hotPosts: '热门讨论', viewCount: '{n} 浏览', likeCount: '{n} 点赞', postStats: '❤️ {likes} 点赞 · 👁 {views} 浏览', toolsTitle: '工具推荐', toolsDesc: '精选热门AI工具' },
circles: { title: '圈子', desc: '按领域划分的垂直讨论区', empty: '暂无圈子', back: '返回发现', members: '人', posts: '帖' },
brand: { name: '宇之然', suffix: 'AI' },
footer: { tagline: '让每个人都能用好 AI', explore: '探索', about: '关于', aboutUs: '关于我们', privacy: '隐私政策', terms: '服务协议', aiAgreement: 'AI 服务协议', contact: '联系方式', copyright: '© {year} 北京宇之然科技中心 版权所有', models: '模型百科', aiTools: 'AI 工具', articles: '文章', skills: '技能' },
models: { title: 'AI 模型百科', desc: '收录主流大语言模型,全面对比各项参数', tableName: '模型名称', tableProvider: '提供商', tableCapabilities: '能力', tableContext: '上下文', tableMaxOutput: '最大输出', tablePricing: '价格', free: '免费', recommended: '推荐', pricingFree: '免费', pricingMixed: '免费/付费', pricingPaid: '付费', contextWindow: '上下文', maxOutput: '最大输出' },
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: '前往技能广场' },
error: { title: '出错了', desc: '页面加载失败,请稍后重试', reload: '重新加载' },
notFound: { title: '404', desc: '页面未找到', backToHome: '返回首页' },
share: { missingToken: '缺少分享参数', invalidLink: '分享链接无效', notAvailable: '分享内容不可用', expired: '该分享链接可能已过期或不存在', goToSandbox: '前往 AI 沙盒', backToSandbox: 'AI 沙盒', modelInfo: '模型: {model} · {date}' },
path: { back: '返回我的', totalProgress: '总进度', taskCount: '{completed}/{total} 任务' },
sandbox: { title: '沙盒', subtitle: '在线体验 AI 对话,边学边练', placeholder: '输入你的问题...', send: '发送', sending: '发送中', newChat: '新对话', history: '历史记录', searchHistory: '搜索历史...', noHistory: '暂无历史记录', sceneGeneral: '通用对话', sceneCoding: '编程助手', sceneWriting: '写作助手', sceneStudy: '学习辅导', sceneEnglish: '英语学习', 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: '登录后可获得更多免费使用次数' },
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: '等待支付中...' },
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: '控制台输出' },
skills: { title: '技能库', desc: '可组合的 AI 学习技能模块', search: '搜索技能...', allCategories: '全部分类', allDifficulties: '全部难度', beginner: '入门', intermediate: '中级', advanced: '高级', tasks: '练习任务', starters: '试试这些问题', prerequisites: '前置技能', apply: '使用此技能', categories: { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业' } },
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: '技能广场' },
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: '已评分' } },
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: '或升级会员获取不限次使用' },
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 助手的全部能力。' },
}
+2 -2
View File
@@ -59,8 +59,8 @@ export function getAssistantContext(pathname: string): AssistantContext {
const sorted = Object.keys(PAGE_CONTEXTS).sort((a, b) => b.length - a.length)
for (const key of sorted) {
if (pathname.startsWith(key)) {
return PAGE_CONTEXTS[key]
return { ...PAGE_CONTEXTS[key], page: key }
}
}
return PAGE_CONTEXTS['/']
return { ...PAGE_CONTEXTS['/'], page: '/' }
}
+5 -10
View File
@@ -1,6 +1,7 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { setTokens, clearTokens, initAuth, isLoggedIn, apiLogout, getToken } from './auth';
interface AuthContextType {
isLoggedIn: boolean;
@@ -17,28 +18,22 @@ const AuthContext = createContext<AuthContextType>({
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
setIsLoggedIn(!!localStorage.getItem('token'));
setInitialized(true);
initAuth().then(() => setInitialized(true));
}, []);
const login = useCallback((token: string, refreshTk?: string) => {
localStorage.setItem('token', token);
if (refreshTk) localStorage.setItem('refreshToken', refreshTk);
setIsLoggedIn(true);
setTokens(token, refreshTk);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
setIsLoggedIn(false);
apiLogout();
}, []);
return (
<AuthContext.Provider value={{ isLoggedIn, initialized, login, logout }}>
<AuthContext.Provider value={{ isLoggedIn: isLoggedIn() || !!getToken(), initialized, login, logout }}>
{children}
</AuthContext.Provider>
);
+52 -15
View File
@@ -1,40 +1,69 @@
import { API_BASE } from '@/lib/config';
let memoryToken: string | null = null;
let memoryRefreshToken: string | null = null;
let memoryAdminToken: string | null = null;
let initPromise: Promise<void> | null = null;
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token');
return memoryToken;
}
export function getAdminToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('adminToken');
return memoryAdminToken;
}
export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refreshToken');
return memoryRefreshToken;
}
export function setTokens(accessToken: string, refreshToken?: string) {
localStorage.setItem('token', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
memoryToken = accessToken;
if (refreshToken) memoryRefreshToken = refreshToken;
}
export function clearTokens() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
memoryToken = null;
memoryRefreshToken = null;
}
export function setAdminToken(token: string) {
memoryAdminToken = token;
}
export function clearAdminToken() {
localStorage.removeItem('adminToken');
memoryAdminToken = null;
}
export function isLoggedIn(): boolean {
return !!getToken();
return !!memoryToken;
}
export function isAdminLoggedIn(): boolean {
return !!getAdminToken();
return !!memoryAdminToken;
}
export async function initAuth(): Promise<void> {
if (initPromise) return initPromise;
if (typeof window === 'undefined') return;
if (memoryToken) return;
initPromise = (async () => {
try {
const res = await fetch(`${API_BASE}/auth/verify`, {
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
if (data.accessToken) memoryToken = data.accessToken;
return;
}
} catch {
}
})();
await initPromise;
initPromise = null;
}
export async function adminApiFetch(url: string, opts: RequestInit = {}): Promise<Response> {
@@ -57,6 +86,7 @@ export async function refreshToken(): Promise<string | null> {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken: token }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
@@ -76,15 +106,22 @@ export async function apiFetch(url: string, opts: RequestInit = {}): Promise<Res
};
if (token) headers['Authorization'] = `Bearer ${token}`;
let res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
let res = await fetch(`${API_BASE}${url}`, { ...opts, headers, credentials: 'include' });
if (res.status === 401 && token) {
const newToken = await refreshToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
res = await fetch(`${API_BASE}${url}`, { ...opts, headers });
res = await fetch(`${API_BASE}${url}`, { ...opts, headers, credentials: 'include' });
}
}
return res;
}
export async function apiLogout(): Promise<void> {
try {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
} catch {}
clearTokens();
}
+9 -1
View File
@@ -1,4 +1,12 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1';
export const API_BASE = (() => {
const configured = process.env.NEXT_PUBLIC_API_URL;
// During SSG/build in Node.js, relative paths like /api/v1 won't resolve
// Use the local backend directly (assumes backend runs on port 4000 during build)
if (typeof window === 'undefined' && configured?.startsWith('/')) {
return 'http://localhost:4000/api/v1';
}
return configured || '/api/v1';
})();
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://yuzhiran.com';

Some files were not shown because too many files have changed in this diff Show More