This commit is contained in:
wendazhi 2026-02-11 15:26:03 +08:00
commit 3c3179ef7d
385 changed files with 68835 additions and 0 deletions

144
.gitea/workflows/README.md Normal file
View File

@ -0,0 +1,144 @@
# Gitea Actions 部署配置说明
## 文件说明
- `online-deploy.yml` - 生产环境部署 workflowmain 分支和 tag
- `develop-deploy.yml` - 开发环境部署 workflowdevelop 分支)
## 配置步骤
### 1. 准备 SSH 密钥
在服务器上生成 SSH 密钥对(如果还没有):
```bash
ssh-keygen -t rsa -b 4096 -C "gitea-actions-deploy" -f ~/.ssh/gitea_deploy_key
```
将公钥添加到服务器的 `~/.ssh/authorized_keys`
```bash
cat ~/.ssh/gitea_deploy_key.pub >> ~/.ssh/authorized_keys
```
### 2. 在 Gitea 中配置 Secrets
1. 进入仓库设置 → Secrets
2. 添加以下 Secret
- **名称**: `SSH_PRIVATE_KEY`
- **值**: 服务器上 `~/.ssh/gitea_deploy_key` 的**私钥内容**(整个文件内容,包括 `-----BEGIN``-----END` 行)
### 3. 确保服务器环境
确保服务器上已安装:
- Node.js 和 npm
- PM2
- Git
- Prisma CLI通过 npm 全局安装或使用 npx
### 4. 触发部署
#### 生产环境部署online-deploy.yml
**自动触发:**
- 推送到 `main` 分支
- 推送标签(格式:`v*`,如 `v1.0.0`
**手动触发:**
1. 进入仓库的 Actions 页面
2. 选择 "生产环境部署" workflow
3. 点击 "Run workflow"
4. 选择要部署的分支或标签
5. 点击 "Run workflow" 开始部署
#### 开发环境部署develop-deploy.yml
**自动触发:**
- 推送到 `develop` 分支
**手动触发:**
1. 进入仓库的 Actions 页面
2. 选择 "开发环境部署" workflow
3. 点击 "Run workflow"
4. 选择要部署的分支(默认 develop
5. 点击 "Run workflow" 开始部署
## Workflow 执行流程
1. ✅ 检出代码
2. ✅ 设置部署分支
3. ✅ 配置 SSH 连接
4. ✅ 测试 SSH 连接
5. ✅ 执行部署脚本(调用服务器上的 `deploy-from-github.sh`
- 拉取最新代码
- 安装依赖
- Prisma generate
- 数据库迁移
- 迁移 Prompt 配置(如果存在)
- 构建项目
- 重启 PM2 服务
- 健康检查
6. ✅ 清理 SSH 密钥
7. ✅ 部署完成通知
## 回滚
如果部署出现问题,可以通过 SSH 连接到服务器手动回滚:
```bash
ssh root@120.55.112.195
cd /var/www/wildgrowth-backend/backend
bash deploy/deploy-from-github.sh rollback
```
## 环境配置说明
### 生产环境online-deploy.yml
- **PM2 服务名**: `wildgrowth-api`
- **健康检查**: `http://localhost:3000/health`
- **部署路径**: `/var/www/wildgrowth-backend/backend`
### 开发环境develop-deploy.yml
- **PM2 服务名**: `wildgrowth-api-dev`(注意:如果开发环境使用不同的服务名,请修改 workflow
- **健康检查**: `http://localhost:3001/health`(注意:如果开发环境使用不同端口,请修改 workflow
- **部署路径**: `/var/www/wildgrowth-backend/backend`(注意:如果开发环境使用不同路径,请修改 workflow
> **提示**: 如果开发环境和生产环境使用相同的服务器但不同的目录或端口,请修改 `develop-deploy.yml` 中的 `APP_ROOT`、`PM2_APP_NAME` 和 `HEALTH_CHECK_URL` 环境变量。
## 注意事项
1. **SSH 密钥安全**: 确保私钥安全,不要提交到代码仓库
2. **服务器权限**: 确保 SSH 用户有足够的权限执行部署操作
3. **环境变量**: 确保服务器上的 `.env` 文件已正确配置
4. **健康检查**:
- 生产环境检查 `http://localhost:3000/health`
- 开发环境检查 `http://localhost:3001/health`(如果使用不同端口)
5. **PM2 服务名**:
- 生产环境使用 `wildgrowth-api`
- 开发环境使用 `wildgrowth-api-dev`(如果使用不同服务名)
## 故障排查
### SSH 连接失败
- 检查 SSH 私钥是否正确配置在 Gitea Secrets 中
- 检查服务器防火墙是否允许 SSH 连接
- 检查服务器的 `~/.ssh/authorized_keys` 是否包含公钥
### 部署失败
- 查看 Actions 日志中的详细错误信息
- SSH 到服务器检查:`pm2 logs wildgrowth-api`
- 检查服务器磁盘空间:`df -h`
- 检查 Node.js 版本:`node -v`
### 健康检查失败
- 检查服务是否正常启动:`pm2 status`
- 查看服务日志:
- 生产环境:`pm2 logs wildgrowth-api --lines 50`
- 开发环境:`pm2 logs wildgrowth-api-dev --lines 50`
- 检查端口是否被占用:
- 生产环境:`netstat -tlnp | grep 3000`
- 开发环境:`netstat -tlnp | grep 3001`

View File

@ -0,0 +1,36 @@
name: Production deploy
on:
push:
branches:
- main
jobs:
build:
runs-on: host
steps:
- name: Ensure Node.js (install if missing)
run: |
if ! command -v node >/dev/null 2>&1; then
echo "Node.js not found, installing..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
else
echo "Node.js already installed: $(node -v)"
fi
- name: Clone repository
run: |
set -e
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
echo "Cloning repo from: ${REPO_URL}"
rm -rf repo
git clone "${REPO_URL}" repo
- name: Copy dist to /data/wildgrowth/weizhuozhongzhi-ai
run: |
set -e
cd repo
mkdir -p /data/wildgrowth/weizhuozhongzhi-ai
rm -rf /data/wildgrowth/weizhuozhongzhi-ai/*
cp -r backend/* /data/wildgrowth/weizhuozhongzhi-ai/

59
.gitignore vendored Normal file
View File

@ -0,0 +1,59 @@
# Dependencies
node_modules/
**/node_modules/
ios/Pods/
ios/Podfile.lock
# Build output
dist/
**/dist/
*.xcuserstate
*.xcworkspace/xcuserdata/
# Environment variables
.env
.env.local
.env.production
backend/.env
backend/.env.production
# Logs
logs/
*.log
**/logs/
# IDE
.vscode/
.idea/
*.swp
*.swo
*.sublime-*
# OS
.DS_Store
**/.DS_Store
Thumbs.db
# Xcode
*.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/
*.xcodeproj/project.xcworkspace/xcuserdata/
# Prisma
prisma/migrations/
# Backup files
*_backup_files/
*.backup
*.bak
# Temporary files
*.tmp
*.temp
tmp/
temp/
# Images (如果图片太大,可以考虑用 Git LFS)
# backend/public/images/*.jpg
# backend/public/images/*.png

3
backend/.env.test Normal file
View File

@ -0,0 +1,3 @@
API_BASE_URL=https://api.muststudy.xin
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=test123456

34
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment variables
.env
.env.local
# 部署记录(仅服务器存在,供 rollback 使用,不提交)
.deploy-last
# Logs
logs/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Prisma
prisma/migrations/

94
backend/README.md Normal file
View File

@ -0,0 +1,94 @@
# 野成长 (Wild Growth) 后端API
## 技术栈
- **运行环境**: Node.js 24+
- **框架**: Express.js
- **语言**: TypeScript
- **数据库**: PostgreSQL
- **ORM**: Prisma
- **认证**: JWT
- **日志**: Winston
## 项目结构
```
backend/
├── src/
│ ├── controllers/ # 控制器(处理请求)
│ ├── services/ # 业务逻辑
│ ├── models/ # 数据模型
│ ├── middleware/ # 中间件
│ ├── routes/ # 路由定义
│ ├── utils/ # 工具函数
│ ├── types/ # TypeScript类型定义
│ └── index.ts # 入口文件
├── prisma/
│ └── schema.prisma # 数据库模型定义
├── logs/ # 日志文件
└── dist/ # 编译后的JavaScript文件
```
## 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 配置环境变量
复制 `.env.example``.env` 并填写配置:
```bash
cp .env.example .env
```
### 3. 设置数据库
确保PostgreSQL已安装并运行然后
```bash
# 生成Prisma Client
npm run prisma:generate
# 运行数据库迁移
npm run prisma:migrate
```
### 4. 启动开发服务器
```bash
npm run dev
```
服务器将在 `http://localhost:3000` 启动
## 开发命令
- `npm run dev` - 启动开发服务器(热重载)
- `npm run build` - 编译TypeScript
- `npm run start` - 运行编译后的代码
- `npm run prisma:generate` - 生成Prisma Client
- `npm run prisma:migrate` - 运行数据库迁移
- `npm run prisma:studio` - 打开Prisma Studio数据库可视化工具
## API文档
详见 `BACKEND_DEVELOPMENT_PLAN.md`
## 环境变量说明
- `PORT`: 服务器端口默认3000
- `NODE_ENV`: 环境development/production
- `DATABASE_URL`: PostgreSQL连接字符串
- `JWT_SECRET`: JWT密钥
- `JWT_EXPIRES_IN`: JWT过期时间
- `SMS_*`: 短信服务配置
- `APPLE_*`: Apple登录配置

132
backend/check_slides.js Normal file
View File

@ -0,0 +1,132 @@
require('dotenv').config();
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkSlides() {
try {
console.log('\n🔍 开始检查数据库中的slides数据...\n');
// 查询所有slides检查paragraphs
const slides = await prisma.nodeSlide.findMany({
take: 50, // 先查50条
orderBy: { createdAt: 'desc' },
include: {
node: {
select: {
title: true,
course: {
select: {
title: true
}
}
}
}
}
});
console.log(`📊 找到 ${slides.length} 条slides记录\n`);
console.log('═'.repeat(80));
let emptyTagCount = 0;
let totalParagraphs = 0;
let problemSlides = [];
for (const slide of slides) {
const content = slide.content;
if (content && content.paragraphs && Array.isArray(content.paragraphs)) {
totalParagraphs += content.paragraphs.length;
let hasProblem = false;
const problems = [];
// 检查每个paragraph
content.paragraphs.forEach((para, index) => {
if (!para || typeof para !== 'string') return;
// 检查空标签
const hasEmptyB = para.includes('<b></b>');
const hasEmptyColor = (para.includes('<color') && para.includes('></color>')) ||
(para.includes('<color') && para.includes('/>'));
const hasEmptySpan = para.includes('<span') && para.includes('></span>');
// 检查标签格式问题
const hasColorWithoutType = para.includes('<color') &&
!para.includes("type='") &&
!para.includes('type="') &&
!para.includes('></color>');
// 检查标签是否被转义
const hasEscapedTags = para.includes('&lt;') || para.includes('&gt;');
if (hasEmptyB || hasEmptyColor || hasEmptySpan || hasColorWithoutType || hasEscapedTags) {
hasProblem = true;
emptyTagCount++;
const issueTypes = [];
if (hasEmptyB) issueTypes.push('空<b>标签');
if (hasEmptyColor) issueTypes.push('空<color>标签');
if (hasEmptySpan) issueTypes.push('空<span>标签');
if (hasColorWithoutType) issueTypes.push('color标签缺少type属性');
if (hasEscapedTags) issueTypes.push('标签被HTML转义');
problems.push({
index,
para: para.substring(0, 150) + (para.length > 150 ? '...' : ''),
issues: issueTypes
});
}
});
if (hasProblem) {
problemSlides.push({
slideId: slide.id,
nodeTitle: slide.node?.title || 'Unknown',
courseTitle: slide.node?.course?.title || 'Unknown',
slideType: slide.slideType,
orderIndex: slide.orderIndex,
problems
});
}
}
}
// 输出统计信息
console.log('\n📈 统计信息:');
console.log(` 总slides数: ${slides.length}`);
console.log(` 总paragraphs数: ${totalParagraphs}`);
console.log(` 有问题的paragraphs数: ${emptyTagCount}`);
console.log(` 有问题的slides数: ${problemSlides.length}`);
// 输出详细问题
if (problemSlides.length > 0) {
console.log('\n⚠ 发现的问题:');
console.log('═'.repeat(80));
problemSlides.forEach((slide, idx) => {
console.log(`\n${idx + 1}. Slide ID: ${slide.slideId}`);
console.log(` 课程: ${slide.courseTitle}`);
console.log(` 节点: ${slide.nodeTitle}`);
console.log(` 类型: ${slide.slideType}, 顺序: ${slide.orderIndex}`);
console.log(` 问题数量: ${slide.problems.length}`);
slide.problems.forEach((prob, pIdx) => {
console.log(`\n 问题 ${pIdx + 1} - Paragraph ${prob.index}:`);
console.log(` 问题类型: ${prob.issues.join(', ')}`);
console.log(` 内容预览: ${prob.para}`);
});
});
} else {
console.log('\n✅ 没有发现空标签问题!');
}
console.log('\n' + '═'.repeat(80));
} catch (error) {
console.error('❌ 错误:', error.message);
console.error(error.stack);
} finally {
await prisma.$disconnect();
}
}
checkSlides();

41
backend/deploy/README.md Normal file
View File

@ -0,0 +1,41 @@
# 部署脚本说明
**部署入口与完整流程见项目根 `DEPLOY_QUICK.md`。** 本文只说明本目录脚本。
---
## 脚本一览
| 脚本 | 用途 |
|------|------|
| **deploy-from-github.sh** | 日常部署与回滚(唯一正式入口) |
| **scripts/server-cleanup-safe.sh** | 服务器安全清理冗余、logs、pm2、npm 缓存) |
| **scripts/migration-fix-and-start.sh** | 仅当 `prisma migrate deploy` 报 P3015 且服务未起时,一次性修复并启动 |
| **check-database.sh** | 数据库连通检查 |
| **setup-ssl-api.sh** / **update-nginx-ssl.sh** | SSL / Nginx 一次性配置 |
| **setup-apple-secret.sh** | Apple IAP 等密钥配置 |
---
## 日常部署(摘要)
```bash
cd /var/www/wildgrowth-backend/backend
bash deploy/deploy-from-github.sh # 部署
bash deploy/deploy-from-github.sh rollback # 回滚
```
---
## ⚠️ 复盘与教训
| 文档 | 说明 |
|------|------|
| **POSTMORTEM_ENV_OVERWRITE.md** | `.env` 被覆盖导致服务中断的复盘(**必读** |
---
## 更多
- **目录约定、首次搭建、手动部署、常见场景**:见项目根 **`DEPLOY_QUICK.md`**
- **清理项说明**:见 **`SERVER_CLEANUP.md`**

View File

@ -0,0 +1,51 @@
#!/bin/bash
# 数据库配置检查脚本
# 用于诊断数据库连接问题
echo "🔍 开始检查数据库配置..."
echo ""
# 1. 检查 PostgreSQL 服务状态
echo "1⃣ 检查 PostgreSQL 服务状态:"
systemctl status postgresql --no-pager | head -5
echo ""
# 2. 检查数据库连接
echo "2⃣ 测试数据库连接:"
PGPASSWORD=yangyichenYANGYICHENkaifa859 psql -h localhost -U postgres -d wildgrowth_app -c "SELECT version();" 2>&1
echo ""
# 3. 检查后端 .env 文件
echo "3⃣ 检查后端 .env 文件:"
if [ -f "/var/www/wildgrowth-backend/backend/.env" ]; then
echo "✅ .env 文件存在"
echo "DATABASE_URL 配置:"
grep "DATABASE_URL" /var/www/wildgrowth-backend/backend/.env | sed 's/:[^@]*@/:***@/g'
else
echo "❌ .env 文件不存在!"
fi
echo ""
# 4. 检查 PM2 服务状态
echo "4⃣ 检查 PM2 服务状态:"
pm2 status wildgrowth-api
echo ""
# 5. 检查后端日志(最近 20 行)
echo "5⃣ 检查后端日志(最近 20 行):"
if [ -f "/var/www/wildgrowth-backend/backend/logs/error.log" ]; then
echo "错误日志:"
tail -20 /var/www/wildgrowth-backend/backend/logs/error.log
else
echo "❌ 错误日志文件不存在"
fi
echo ""
# 6. 检查数据库是否存在
echo "6⃣ 检查数据库是否存在:"
PGPASSWORD=yangyichenYANGYICHENkaifa859 psql -h localhost -U postgres -lqt | cut -d \| -f 1 | grep -qw wildgrowth_app && echo "✅ 数据库 wildgrowth_app 存在" || echo "❌ 数据库 wildgrowth_app 不存在"
echo ""
echo "✅ 检查完成!"

View File

@ -0,0 +1,30 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkNotesTable() {
try {
const result = await prisma.$queryRaw`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'notes'
ORDER BY ordinal_position
`;
console.log('Notes table columns:');
console.log(JSON.stringify(result, null, 2));
// Check if table exists
const tableExists = await prisma.$queryRaw`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'notes'
)
`;
console.log('\nTable exists:', tableExists[0].exists);
} catch (error) {
console.error('Error:', error.message);
} finally {
await prisma.$disconnect();
}
}
checkNotesTable();

View File

@ -0,0 +1,48 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const fs = require('fs');
const path = require('path');
async function createNotesTable() {
try {
// Read migration SQL
const migrationPath = path.join(__dirname, 'prisma/migrations/20260113_simplify_notes/migration.sql');
const sql = fs.readFileSync(migrationPath, 'utf8');
// Execute SQL (split by semicolons for multiple statements)
const statements = sql.split(';').filter(s => s.trim().length > 0);
for (const statement of statements) {
if (statement.trim()) {
try {
await prisma.$executeRawUnsafe(statement.trim() + ';');
console.log('✓ Executed statement');
} catch (error) {
// Ignore errors for IF EXISTS / IF NOT EXISTS statements
if (!error.message.includes('already exists') && !error.message.includes('does not exist')) {
console.error('Error executing statement:', error.message);
}
}
}
}
console.log('\n✅ Migration completed');
// Verify table exists
const result = await prisma.$queryRaw`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'notes'
ORDER BY ordinal_position
`;
console.log('\nNotes table columns:');
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error('Error:', error.message);
console.error(error);
} finally {
await prisma.$disconnect();
}
}
createNotesTable();

View File

@ -0,0 +1,110 @@
#!/bin/bash
# ============================================
# 部署课程生成重构(支持三种模式)
# ============================================
# 使用方法:
# ssh root@120.55.112.195
# cd /var/www/wildgrowth-backend/backend && bash deploy/deploy-course-generation-refactor.sh
# ============================================
set -e
GIT_ROOT="/var/www/wildgrowth-backend"
APP_ROOT="/var/www/wildgrowth-backend/backend"
BRANCH=${1:-main}
echo "═══════════════════════════════════════════════════════════"
echo " 🚀 开始部署课程生成重构"
echo " 📦 部署分支: $BRANCH"
echo "═══════════════════════════════════════════════════════════"
echo ""
if [ ! -d "$APP_ROOT" ]; then
echo "❌ 错误: $APP_ROOT 不存在"
exit 1
fi
if [ ! -f "$APP_ROOT/.env" ]; then
echo "❌ 错误: $APP_ROOT/.env 不存在"
exit 1
fi
# 1. 拉取最新代码
echo "📥 步骤 1: 拉取最新代码..."
(cd "$GIT_ROOT" && git fetch origin)
(cd "$GIT_ROOT" && git checkout $BRANCH && git pull origin $BRANCH)
echo "✅ 代码已更新 (分支: $BRANCH)"
echo ""
# 2. 安装依赖
echo "📦 步骤 2: 安装依赖..."
(cd "$APP_ROOT" && npm install)
echo "✅ 依赖已安装"
echo ""
# 3. Prisma generate
echo "🔧 步骤 3: Prisma generate..."
(cd "$APP_ROOT" && npx prisma generate)
echo "✅ Prisma generate 完成"
echo ""
# 4. 数据库迁移
echo "🗄️ 步骤 4: 数据库迁移..."
MIGRATIONS="$APP_ROOT/prisma/migrations"
if [ -d "$MIGRATIONS" ]; then
for d in "$MIGRATIONS"/*/; do
[ -d "$d" ] || continue
if [ ! -f "${d}migration.sql" ]; then
echo " 删除残缺 migration 目录: $(basename "$d")"
rm -rf "$d"
fi
done
fi
(cd "$APP_ROOT" && npx prisma migrate deploy)
echo "✅ 数据库迁移完成"
echo ""
# 5. 迁移Prompt配置
echo "📝 步骤 5: 迁移Prompt配置..."
(cd "$APP_ROOT" && npx ts-node scripts/migrate-prompt-configs.ts)
echo "✅ Prompt配置迁移完成"
echo ""
# 6. 构建
echo "🔨 步骤 6: 构建项目..."
(cd "$APP_ROOT" && npm run build)
echo "✅ 项目已构建"
echo ""
# 7. 重启服务
echo "🔄 步骤 7: 重启服务..."
pm2 restart wildgrowth-api
echo "✅ 服务已重启"
echo ""
# 8. 健康检查
echo "🏥 步骤 8: 健康检查..."
sleep 3
if curl -sf http://localhost:3000/health > /dev/null; then
echo "✅ 健康检查通过"
(cd "$GIT_ROOT" && git rev-parse HEAD) > "$APP_ROOT/.deploy-last"
echo " (已记录到 .deploy-last供 rollback 使用)"
else
echo "❌ 健康检查失败: curl http://localhost:3000/health 未返回成功"
echo " 请检查: pm2 logs wildgrowth-api"
exit 1
fi
echo ""
# 9. 状态与日志
echo "📊 步骤 9: 服务状态"
pm2 status wildgrowth-api
echo "📝 最近日志:"
pm2 logs wildgrowth-api --lines 20 --nostream
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " ✅ 部署完成!分支: $BRANCH"
echo "═══════════════════════════════════════════════════════════"
echo "💡 回滚: bash deploy/deploy-from-github.sh rollback"
echo ""

View File

@ -0,0 +1,190 @@
#!/bin/bash
# ============================================
# 从 GitHub 部署到生产环境迁移后Git 根 = wildgrowth-backend应用根 = backend
# ============================================
# 使用方法:
# ssh root@120.55.112.195
# cd /var/www/wildgrowth-backend/backend && bash deploy/deploy-from-github.sh [分支名]
# 默认分支1.30dazhi合并前(不部署 main 上的错误合并)
# git pull 超时 60s失败会提示在本机执行 backend/deploy/deploy-rsync-from-local.sh
# SKIP_GIT_PULL=1 时跳过拉取(供 rsync 回退后使用GIT_PULL_TIMEOUT=90 可改超时秒数
# 回滚bash deploy/deploy-from-github.sh rollback
# ============================================
set -e
GIT_ROOT="/var/www/wildgrowth-backend"
APP_ROOT="/var/www/wildgrowth-backend/backend"
# ---------- 回滚模式 ----------
if [ "$1" = "rollback" ]; then
echo "═══════════════════════════════════════════════════════════"
echo " 🔄 回滚模式:回退到上次成功部署的版本"
echo "═══════════════════════════════════════════════════════════"
echo ""
if [ ! -d "$APP_ROOT" ]; then
echo "❌ 错误: $APP_ROOT 不存在"
exit 1
fi
if [ ! -f "$APP_ROOT/.deploy-last" ]; then
echo "❌ 无上次部署记录(.deploy-last 不存在)"
echo " 请手动git -C $GIT_ROOT log --oneline -5checkout <commit> 后重新 build 并 pm2 restart"
exit 1
fi
PREV=$(cat "$APP_ROOT/.deploy-last" | tr -d '[:space:]')
if [ -z "$PREV" ]; then
echo "❌ .deploy-last 为空,无法回滚"
exit 1
fi
echo "📌 回滚到: $PREV"
echo ""
(cd "$GIT_ROOT" && git fetch origin && git checkout "$PREV")
echo "📦 安装依赖..."
(cd "$APP_ROOT" && npm install)
echo "🔧 Prisma generate..."
(cd "$APP_ROOT" && npx prisma generate)
echo "🗄️ 数据库迁移..."
MIGRATIONS="$APP_ROOT/prisma/migrations"
if [ -d "$MIGRATIONS" ]; then
for d in "$MIGRATIONS"/*/; do
[ -d "$d" ] || continue
if [ ! -f "${d}migration.sql" ]; then rm -rf "$d"; fi
done
fi
(cd "$APP_ROOT" && npx prisma migrate deploy)
echo "🔨 构建..."
(cd "$APP_ROOT" && npm run build)
echo "🔄 重启服务..."
pm2 restart wildgrowth-api
echo "⏳ 等待 3s 后健康检查..."
sleep 3
if curl -sf http://localhost:3000/health > /dev/null; then
echo "✅ 回滚完成,服务正常"
else
echo "⚠️ 健康检查未通过,请检查: pm2 logs wildgrowth-api"
fi
echo ""
exit 0
fi
# ---------- 正常部署 ----------
BRANCH=${1:-1.30dazhi合并前}
GIT_PULL_TIMEOUT=${GIT_PULL_TIMEOUT:-60}
echo "═══════════════════════════════════════════════════════════"
echo " 🚀 开始从 GitHub 部署到生产环境"
echo " 📦 部署分支: $BRANCH"
echo "═══════════════════════════════════════════════════════════"
echo ""
if [ ! -d "$APP_ROOT" ]; then
echo "❌ 错误: $APP_ROOT 不存在"
exit 1
fi
if [ ! -f "$APP_ROOT/.env" ]; then
echo "❌ 错误: $APP_ROOT/.env 不存在"
exit 1
fi
# 1. 拉取最新代码(在 Git 根);若 SKIP_GIT_PULL=1 则跳过(供 rsync 回退后使用)
if [ -n "$SKIP_GIT_PULL" ]; then
echo "📥 步骤 1: 跳过 git pullSKIP_GIT_PULL=1代码已由 rsync 同步)"
else
echo "📥 步骤 1: 拉取最新代码(超时 ${GIT_PULL_TIMEOUT}s..."
(cd "$GIT_ROOT" && git fetch origin)
if ! (cd "$GIT_ROOT" && git show-ref --verify --quiet refs/remotes/origin/$BRANCH); then
echo "❌ 分支 $BRANCH 不存在"
(cd "$GIT_ROOT" && git branch -r | grep -v HEAD | sed 's/origin\///' | sed 's/^/ - /')
exit 1
fi
if ! (cd "$GIT_ROOT" && timeout "$GIT_PULL_TIMEOUT" git checkout $BRANCH && timeout "$GIT_PULL_TIMEOUT" git pull origin $BRANCH); then
echo ""
echo "❌ Git 拉取失败(超时或网络/凭据问题)。请在本机执行 rsync 回退部署:"
echo " cd <项目根目录> && bash backend/deploy/deploy-rsync-from-local.sh"
echo " 详见 DEPLOY_QUICK.md「三、git pull 超时rsync 回退部署」。"
exit 1
fi
echo "✅ 代码已更新 (分支: $BRANCH)"
fi
echo ""
# 2. 安装依赖
echo "📦 步骤 2: 安装依赖..."
(cd "$APP_ROOT" && npm install)
echo "✅ 依赖已安装"
echo ""
# 3. Prisma generate
echo "🔧 步骤 3: Prisma generate..."
(cd "$APP_ROOT" && npx prisma generate)
echo ""
# 4. 数据库迁移(先删缺 migration.sql 的目录,避免 P3015
echo "🗄️ 步骤 4: 数据库迁移..."
MIGRATIONS="$APP_ROOT/prisma/migrations"
if [ -d "$MIGRATIONS" ]; then
for d in "$MIGRATIONS"/*/; do
[ -d "$d" ] || continue
if [ ! -f "${d}migration.sql" ]; then
echo " 删除残缺 migration 目录: $(basename "$d")"
rm -rf "$d"
fi
done
fi
(cd "$APP_ROOT" && npx prisma migrate deploy)
echo "✅ 数据库迁移完成"
echo ""
# 4.5. 迁移Prompt配置如果脚本存在
echo "📝 步骤 4.5: 迁移Prompt配置..."
if [ -f "$APP_ROOT/scripts/migrate-prompt-configs.ts" ]; then
(cd "$APP_ROOT" && npx ts-node scripts/migrate-prompt-configs.ts) || echo "⚠️ Prompt配置迁移失败但继续部署"
echo "✅ Prompt配置迁移完成"
else
echo " Prompt配置迁移脚本不存在跳过"
fi
echo ""
# 5. 构建
echo "🔨 步骤 5: 构建项目..."
(cd "$APP_ROOT" && npm run build)
echo "✅ 项目已构建"
echo ""
# 6. 重启服务
echo "🔄 步骤 6: 重启服务..."
pm2 restart wildgrowth-api
echo ""
# 7. 健康检查
echo "🏥 步骤 7: 健康检查..."
sleep 2
if curl -sf http://localhost:3000/health > /dev/null; then
echo "✅ 健康检查通过"
(cd "$GIT_ROOT" && git rev-parse HEAD) > "$APP_ROOT/.deploy-last"
echo " (已记录到 .deploy-last供 rollback 使用)"
else
echo "❌ 健康检查失败: curl http://localhost:3000/health 未返回成功"
echo " 请检查: pm2 logs wildgrowth-api"
exit 1
fi
echo ""
# 8. 状态与日志
echo "📊 步骤 8: 服务状态"
pm2 status wildgrowth-api
echo "📝 最近日志:"
pm2 logs wildgrowth-api --lines 20 --nostream
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " ✅ 部署完成!分支: $BRANCH"
echo "═══════════════════════════════════════════════════════════"
echo "💡 回滚: bash deploy/deploy-from-github.sh rollback"
echo ""

View File

@ -0,0 +1,69 @@
#!/bin/bash
# ============================================
# 本机 → 服务器 rsync 回退部署(当服务器 git pull 超时/失败时使用)
# ============================================
# 使用方法(在项目根目录执行):
# export RSYNC_DEPLOY_PASSWORD='你的SSH密码' # 同 DEPLOY_QUICK.md 部署凭据
# bash backend/deploy/deploy-rsync-from-local.sh
# 或一行(密码同部署凭据):
# RSYNC_DEPLOY_PASSWORD='yangyichenYANGYICHENkaifa859' bash backend/deploy/deploy-rsync-from-local.sh
# ============================================
# 会做rsync backend/ 到服务器(--delete再在服务器执行 install/prisma/build/pm2 重启。
# ============================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd "$BACKEND_ROOT/.." && pwd)"
SERVER_HOST="${RSYNC_DEPLOY_HOST:-120.55.112.195}"
SERVER_USER="${RSYNC_DEPLOY_USER:-root}"
SERVER_BACKEND="/var/www/wildgrowth-backend/backend"
if [ -z "$RSYNC_DEPLOY_PASSWORD" ]; then
echo "❌ 请设置 RSYNC_DEPLOY_PASSWORD同 DEPLOY_QUICK.md 部署凭据密码)"
echo " export RSYNC_DEPLOY_PASSWORD='你的密码'"
echo " 或: RSYNC_DEPLOY_PASSWORD='...' bash backend/deploy/deploy-rsync-from-local.sh"
exit 1
fi
echo "═══════════════════════════════════════════════════════════"
echo " 📤 Rsync 回退部署:本机 backend/ → 服务器"
echo " 主机: $SERVER_USER@$SERVER_HOST"
echo "═══════════════════════════════════════════════════════════"
echo ""
# 1. rsync--delete 保持服务器与本地一致,排除无需同步的目录)
echo "📂 步骤 1: rsync 同步(--delete..."
export RSYNC_RSH="sshpass -p \"$RSYNC_DEPLOY_PASSWORD\" ssh -o StrictHostKeyChecking=no"
rsync -avz --delete \
--exclude=node_modules \
--exclude='.env*' \
--exclude=dist \
--exclude=.deploy-last \
--exclude=.git \
--exclude=public/uploads \
--exclude=logs \
"$BACKEND_ROOT/" "$SERVER_USER@$SERVER_HOST:$SERVER_BACKEND/"
unset RSYNC_RSH
echo "✅ 同步完成"
echo ""
# 2. 在服务器执行部署步骤(跳过 git pull
echo "🔄 步骤 2: 在服务器执行 install / prisma / build / pm2 restart..."
if command -v sshpass >/dev/null 2>&1; then
sshpass -p "$RSYNC_DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_HOST" \
"cd $SERVER_BACKEND && SKIP_GIT_PULL=1 bash deploy/deploy-from-github.sh"
else
echo "⚠️ 未安装 sshpass请手动 SSH 后执行:"
echo " cd $SERVER_BACKEND && SKIP_GIT_PULL=1 bash deploy/deploy-from-github.sh"
exit 1
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " ✅ Rsync 回退部署完成"
echo "═══════════════════════════════════════════════════════════"

View File

@ -0,0 +1,62 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function fixConstraints() {
try {
// Create indexes
await prisma.$executeRawUnsafe(`
CREATE INDEX IF NOT EXISTS "notes_user_id_course_id_idx" ON "notes"("user_id", "course_id");
`);
console.log('✓ Created index: notes_user_id_course_id_idx');
await prisma.$executeRawUnsafe(`
CREATE INDEX IF NOT EXISTS "notes_user_id_node_id_idx" ON "notes"("user_id", "node_id");
`);
console.log('✓ Created index: notes_user_id_node_id_idx');
await prisma.$executeRawUnsafe(`
CREATE INDEX IF NOT EXISTS "notes_course_id_node_id_idx" ON "notes"("course_id", "node_id");
`);
console.log('✓ Created index: notes_course_id_node_id_idx');
// Create foreign keys (check if they exist first)
const constraints = await prisma.$queryRaw`
SELECT conname FROM pg_constraint
WHERE conrelid = 'notes'::regclass
AND contype = 'f'
`;
const existingConstraints = constraints.map(c => c.conname);
if (!existingConstraints.includes('notes_user_id_fkey')) {
await prisma.$executeRawUnsafe(`
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
`);
console.log('✓ Created foreign key: notes_user_id_fkey');
}
if (!existingConstraints.includes('notes_course_id_fkey')) {
await prisma.$executeRawUnsafe(`
ALTER TABLE "notes" ADD CONSTRAINT "notes_course_id_fkey"
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
`);
console.log('✓ Created foreign key: notes_course_id_fkey');
}
if (!existingConstraints.includes('notes_node_id_fkey')) {
await prisma.$executeRawUnsafe(`
ALTER TABLE "notes" ADD CONSTRAINT "notes_node_id_fkey"
FOREIGN KEY ("node_id") REFERENCES "course_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
`);
console.log('✓ Created foreign key: notes_node_id_fkey');
}
console.log('\n✅ All constraints created successfully');
} catch (error) {
console.error('Error:', error.message);
} finally {
await prisma.$disconnect();
}
}
fixConstraints();

View File

@ -0,0 +1,100 @@
#!/bin/bash
# ============================================================
# 迁移收尾:修 P3015删残缺 migration 目录、migrate deploy、启动、健康检查
# 用法:在服务器上
# cd /var/www/wildgrowth-backend/backend && bash deploy/scripts/migration-fix-and-start.sh
# 或(若脚本在 deploy 下bash deploy/scripts/migration-fix-and-start.sh
# ============================================================
set -e
GIT_ROOT="${GIT_ROOT:-/var/www/wildgrowth-backend}"
APP_ROOT="${APP_ROOT:-/var/www/wildgrowth-backend/backend}"
MIGRATIONS="$APP_ROOT/prisma/migrations"
echo "═══════════════════════════════════════════════════════════"
echo " 迁移收尾:修复 P3015、migrate、启动、健康检查"
echo " APP_ROOT=$APP_ROOT"
echo "═══════════════════════════════════════════════════════════"
if [ ! -d "$APP_ROOT" ]; then
echo "❌ 错误: $APP_ROOT 不存在"
exit 1
fi
if [ ! -f "$APP_ROOT/.env" ]; then
echo "❌ 错误: $APP_ROOT/.env 不存在"
exit 1
fi
cd "$APP_ROOT"
# 1. 删除缺 migration.sql 的目录(解决 P3015
echo ""
echo "1⃣ 检查 migrations删除残缺目录..."
if [ -d "$MIGRATIONS" ]; then
for d in "$MIGRATIONS"/*/; do
[ -d "$d" ] || continue
name=$(basename "$d")
if [ ! -f "${d}migration.sql" ]; then
echo " 删除残缺目录(无 migration.sql: $name"
rm -rf "$d"
else
echo " OK: $name"
fi
done
else
echo " ⚠️ prisma/migrations 不存在,跳过"
fi
# 2. Prisma migrate deploy
echo ""
echo "2⃣ 执行 prisma migrate deploy..."
npx prisma migrate deploy
echo "✅ 迁移完成"
# 3. 若未 build 则 build迁移后可能已有 dist避免重复
if [ ! -d "dist" ] || [ ! -f "dist/index.js" ]; then
echo ""
echo "3⃣ 构建..."
npm run build
else
echo ""
echo "3⃣ dist 已存在,跳过 build"
fi
# 4. 启动
echo ""
echo "4⃣ 启动服务..."
if pm2 describe wildgrowth-api >/dev/null 2>&1; then
pm2 restart wildgrowth-api
echo " pm2 restart wildgrowth-api"
else
# 未在 pm2 中则 start
pm2 start dist/index.js --name wildgrowth-api
echo " pm2 start dist/index.js --name wildgrowth-api"
fi
# 5. 健康检查
echo ""
echo "5⃣ 健康检查..."
sleep 3
if curl -sf http://localhost:3000/health >/dev/null; then
echo "✅ 本机 health 正常"
else
echo "❌ 本机 health 失败: curl http://localhost:3000/health"
pm2 logs wildgrowth-api --lines 30 --nostream
exit 1
fi
# 6. 记录 .deploy-last若在 Git 根可拿到 HEAD
if [ -d "$GIT_ROOT/.git" ]; then
(cd "$GIT_ROOT" && git rev-parse HEAD) > "$APP_ROOT/.deploy-last"
echo " .deploy-last 已更新"
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " ✅ 迁移收尾完成,服务已启动"
echo " 请自测: https://api.muststudy.xin/health"
echo " 回滚: bash deploy/deploy-from-github.sh rollback"
echo "═══════════════════════════════════════════════════════════"

View File

@ -0,0 +1,65 @@
#!/bin/bash
# ============================================================
# 服务器安全清理:只删冗余与可再生的,不动跑服务的代码
# 用法cd /var/www/wildgrowth-backend/backend && bash deploy/scripts/server-cleanup-safe.sh
# 迁移后:无 backend/backend无 sync应用根 = backend/
# ============================================================
set -e
GIT_ROOT="/var/www/wildgrowth-backend"
BACKEND="$GIT_ROOT/backend"
cd "$BACKEND" || { echo "❌ 目录不存在: $BACKEND"; exit 1; }
echo "═══════════════════════════════════════════════════════════"
echo " 服务器安全清理(不动 src、prisma、deploy、.env、dist"
echo "═══════════════════════════════════════════════════════════"
# 1. 迁移后冗余:嵌套 backend/ 或 backend/backend 整目录删除(已无 sync不再需要
echo ""
echo "1⃣ 清理冗余嵌套 backend..."
removed=
if [ -d "backend/backend" ]; then rm -rf backend/backend && removed=1; fi
if [ -d "backend" ]; then rm -rf backend && removed=1; fi # 应用根下的 backend 子目录
[ -n "$removed" ] && echo " ✅ 已删冗余 backend/" || echo " (无冗余 backend跳过)"
# 2. 应用根下本机冗余:.DS_Store、一次性脚本
echo ""
echo "2⃣ 清理应用根下冗余文件..."
for f in .DS_Store compile_content_service.sh deploy-fix-next-lesson.sh; do
[ -f "$f" ] && rm -f "$f" && echo " 已删 $f"
done
# 3. 旧备份(若存在)
if ls "$GIT_ROOT"/backend-backup-* 1>/dev/null 2>&1; then
rm -rf "$GIT_ROOT"/backend-backup-*
echo " ✅ 已删旧备份 backend-backup-*"
fi
# 4. 应用日志截断
echo ""
echo "4⃣ 截断 logs..."
if [ -f "logs/combined.log" ]; then
s=$(du -sh logs/combined.log 2>/dev/null | cut -f1)
> logs/combined.log
echo " 已截断 logs/combined.log (原 $s)"
fi
if [ -f "logs/error.log" ]; then
> logs/error.log
echo " 已截断 logs/error.log"
fi
# 5. PM2 日志
echo ""
echo "5⃣ 清空 PM2 日志..."
pm2 flush 2>/dev/null && echo " ✅ pm2 flush 完成" || echo " ⚠️ pm2 flush 失败或未安装"
# 6. npm 缓存
echo ""
echo "6⃣ 清空 npm 缓存..."
npm cache clean --force 2>/dev/null && echo " ✅ npm cache 已清理" || echo " ⚠️ npm cache 清理失败"
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " 安全清理完成"
echo " 可选(本机不构建 iOS 时rm -rf $GIT_ROOT/ios 释放约 5Mgit pull 会拉回)"
echo "═══════════════════════════════════════════════════════════"

View File

@ -0,0 +1,61 @@
#!/bin/bash
# Apple Shared Secret 配置脚本
# 使用方法:./setup_apple_secret.sh YOUR_SECRET_HERE
if [ -z "$1" ]; then
echo "❌ 错误:请提供 Apple Shared Secret"
echo "使用方法:./setup_apple_secret.sh YOUR_SECRET_HERE"
exit 1
fi
SECRET="$1"
SERVER="root@120.55.112.195"
PASSWORD="yangyichenYANGYICHENkaifa859"
echo "🔐 开始配置 Apple Shared Secret..."
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no "$SERVER" << EOF
cd /var/www/wildgrowth-backend/backend
# 备份原文件
cp .env .env.backup.\$(date +%Y%m%d_%H%M%S)
echo "✅ 已备份原配置文件"
# 更新 APPLE_SHARED_SECRET
if grep -q "APPLE_SHARED_SECRET=" .env; then
sed -i "s|APPLE_SHARED_SECRET=.*|APPLE_SHARED_SECRET=$SECRET|" .env
echo "✅ 已更新 APPLE_SHARED_SECRET"
else
echo "APPLE_SHARED_SECRET=$SECRET" >> .env
echo "✅ 已添加 APPLE_SHARED_SECRET"
fi
# 验证配置
echo ""
echo "📋 配置验证:"
grep APPLE_SHARED_SECRET .env | sed 's/=.*/=***已配置(长度:'${#SECRET}'字符)***/'
# 重启服务
echo ""
echo "🔄 重启服务..."
pm2 restart wildgrowth-api
# 等待服务启动
sleep 2
# 检查服务状态
echo ""
echo "📊 服务状态:"
pm2 list | grep wildgrowth-api
echo ""
echo "✅ 配置完成!"
EOF
echo ""
echo "🎉 Apple Shared Secret 配置成功!"
echo ""
echo "📝 下一步:"
echo " 1. 在 App Store Connect 创建内购产品"
echo " 2. 测试 IAP 验证接口"
echo " 3. 使用沙盒账号进行测试"

182
backend/deploy/setup-ssl-api.sh Executable file
View File

@ -0,0 +1,182 @@
#!/bin/bash
# ============================================
# SSL 证书配置脚本Let's Encrypt
# ============================================
# 用途:为 api.muststudy.xin 配置 HTTPS
# 使用方法:在服务器上执行 bash deploy/setup-ssl-api.sh
# ============================================
set -e
# 颜色
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🔒 开始配置 SSL 证书...${NC}"
echo ""
# 配置变量
DOMAIN="api.muststudy.xin"
NGINX_CONF="/etc/nginx/conf.d/wildgrowth-api.conf"
# ============================================
# 第一步:检查并安装 Certbot
# ============================================
echo -e "${BLUE}📦 第一步:检查 Certbot...${NC}"
if ! command -v certbot &> /dev/null; then
echo -e "${YELLOW}⚠️ Certbot 未安装,开始安装...${NC}"
# 检测系统类型
if [ -f /etc/redhat-release ]; then
# CentOS/RHEL
yum install -y epel-release
yum install -y certbot python3-certbot-nginx
elif [ -f /etc/debian_version ]; then
# Debian/Ubuntu
apt-get update
apt-get install -y certbot python3-certbot-nginx
else
echo -e "${RED}❌ 无法检测系统类型,请手动安装 certbot${NC}"
exit 1
fi
echo -e "${GREEN}✅ Certbot 安装完成${NC}"
else
echo -e "${GREEN}✅ Certbot 已安装${NC}"
fi
echo ""
# ============================================
# 第二步:确保 Nginx 配置存在HTTP
# ============================================
echo -e "${BLUE}🌐 第二步:检查 Nginx 配置...${NC}"
if [ ! -f "$NGINX_CONF" ]; then
echo -e "${YELLOW}⚠️ Nginx 配置文件不存在,创建基础配置...${NC}"
cat > $NGINX_CONF <<'EOF'
server {
listen 80;
server_name api.muststudy.xin;
# 日志
access_log /var/log/nginx/wildgrowth-api-access.log;
error_log /var/log/nginx/wildgrowth-api-error.log;
# 上传文件大小限制
client_max_body_size 10M;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
EOF
# 测试并重载 Nginx
if nginx -t; then
systemctl reload nginx
echo -e "${GREEN}✅ Nginx 配置已创建${NC}"
else
echo -e "${RED}❌ Nginx 配置有误${NC}"
exit 1
fi
else
echo -e "${GREEN}✅ Nginx 配置文件已存在${NC}"
fi
echo ""
# ============================================
# 第三步:申请 SSL 证书
# ============================================
echo -e "${BLUE}🔐 第三步:申请 SSL 证书...${NC}"
echo -e "${YELLOW}⚠️ 这将为 ${DOMAIN} 申请 Let's Encrypt 证书${NC}"
echo ""
# 检查证书是否已存在
if [ -d "/etc/letsencrypt/live/${DOMAIN}" ]; then
echo -e "${YELLOW}⚠️ 证书已存在,是否续期?${NC}"
read -p "续期证书?(y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
certbot renew --dry-run
echo -e "${GREEN}✅ 证书续期测试完成${NC}"
else
echo -e "${YELLOW}⚠️ 跳过证书续期${NC}"
fi
else
# 申请新证书
echo -e "${BLUE}正在申请 SSL 证书...${NC}"
certbot --nginx -d $DOMAIN --non-interactive --agree-tos --email admin@muststudy.xin
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ SSL 证书申请成功${NC}"
else
echo -e "${RED}❌ SSL 证书申请失败${NC}"
echo -e "${YELLOW}提示:请确保:${NC}"
echo " 1. 域名 ${DOMAIN} 已正确解析到服务器 IP"
echo " 2. 防火墙已开放 80 和 443 端口"
echo " 3. Nginx 正在运行"
exit 1
fi
fi
echo ""
# ============================================
# 第四步:验证 SSL 配置
# ============================================
echo -e "${BLUE}✅ 第四步:验证 SSL 配置...${NC}"
# 测试 Nginx 配置
if nginx -t; then
systemctl reload nginx
echo -e "${GREEN}✅ Nginx 配置验证通过${NC}"
else
echo -e "${RED}❌ Nginx 配置验证失败${NC}"
exit 1
fi
# 测试 HTTPS 连接
echo ""
echo -e "${BLUE}测试 HTTPS 连接...${NC}"
if curl -s -k https://${DOMAIN}/health > /dev/null; then
echo -e "${GREEN}✅ HTTPS 连接正常${NC}"
else
echo -e "${YELLOW}⚠️ HTTPS 连接测试失败,请检查配置${NC}"
fi
echo ""
echo "============================================"
echo -e "${GREEN}🎉 SSL 配置完成!${NC}"
echo "============================================"
echo ""
echo "📊 验证信息:"
echo " - 证书路径: /etc/letsencrypt/live/${DOMAIN}/"
echo " - HTTPS URL: https://${DOMAIN}"
echo ""
echo "📝 证书自动续期:"
echo " Let's Encrypt 证书有效期为 90 天"
echo " Certbot 会自动续期,或手动运行: certbot renew"
echo ""
echo "🌐 测试命令:"
echo " curl https://${DOMAIN}/health"
echo ""

View File

@ -0,0 +1,122 @@
#!/bin/bash
# ============================================
# 更新 Nginx 配置以启用 HTTPS
# ============================================
# 用途:为 api.muststudy.xin 配置 HTTPS使用现有证书
# 使用方法:在服务器上执行 bash deploy/update-nginx-ssl.sh
# ============================================
set -e
# 颜色
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🔒 更新 Nginx 配置以启用 HTTPS...${NC}"
echo ""
DOMAIN="api.muststudy.xin"
NGINX_CONF="/etc/nginx/conf.d/wildgrowth-api.conf"
CERT_PATH="/etc/letsencrypt/live/${DOMAIN}"
# 检查证书是否存在
if [ ! -d "$CERT_PATH" ]; then
echo -e "${RED}❌ SSL 证书不存在: ${CERT_PATH}${NC}"
echo -e "${YELLOW}请先运行: bash deploy/setup-ssl-api.sh${NC}"
exit 1
fi
echo -e "${GREEN}✅ 找到 SSL 证书: ${CERT_PATH}${NC}"
echo ""
# 更新 Nginx 配置
echo -e "${BLUE}📝 更新 Nginx 配置...${NC}"
cat > $NGINX_CONF <<'EOF'
# HTTP 重定向到 HTTPS
server {
listen 80;
server_name api.muststudy.xin;
# Let's Encrypt 验证
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 其他请求重定向到 HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
# HTTPS 配置
server {
listen 443 ssl http2;
server_name api.muststudy.xin;
# SSL 证书配置
ssl_certificate /etc/letsencrypt/live/api.muststudy.xin/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.muststudy.xin/privkey.pem;
# SSL 安全配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 日志
access_log /var/log/nginx/wildgrowth-api-access.log;
error_log /var/log/nginx/wildgrowth-api-error.log;
# 上传文件大小限制
client_max_body_size 10M;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# 超时设置增加到5分钟支持长时间运行的AI生成任务
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
}
EOF
# 测试 Nginx 配置
echo -e "${BLUE}🔍 测试 Nginx 配置...${NC}"
if nginx -t; then
echo -e "${GREEN}✅ Nginx 配置验证通过${NC}"
# 重载 Nginx
systemctl reload nginx
echo -e "${GREEN}✅ Nginx 已重载${NC}"
else
echo -e "${RED}❌ Nginx 配置验证失败${NC}"
exit 1
fi
echo ""
echo "============================================"
echo -e "${GREEN}🎉 HTTPS 配置完成!${NC}"
echo "============================================"
echo ""
echo "📊 配置信息:"
echo " - HTTP (80): 自动重定向到 HTTPS"
echo " - HTTPS (443): 已启用 SSL"
echo " - 证书路径: ${CERT_PATH}"
echo ""
echo "🌐 测试命令:"
echo " curl https://${DOMAIN}/health"
echo ""

View File

@ -0,0 +1,83 @@
# ============================================
# 生产环境配置文件模板
# ============================================
# 使用说明:
# 1. 复制此文件为 .env.production
# 2. 填写所有必需的环境变量
# 3. 确保此文件不会被提交到 Git已在 .gitignore 中)
# ============================================
# ========== 服务器配置 ==========
NODE_ENV=production
PORT=3000
# ========== 数据库配置 ==========
# PostgreSQL 连接字符串
# 格式postgresql://用户名:密码@主机:端口/数据库名?schema=public
# 注意:如果 PostgreSQL 在同一台服务器上,使用 localhost
# 如果使用远程数据库,使用实际 IP 或域名
DATABASE_URL=postgresql://postgres:yangyichenYANGYICHENkaifa859@localhost:5432/wildgrowth_app?schema=public
# ========== JWT 认证配置 ==========
# JWT 密钥(用于生成和验证 Token
# 必须使用强随机字符串,至少 32 个字符
# 生成命令openssl rand -base64 32 | tr -d "=+/" | cut -c1-32
JWT_SECRET=IZLHw83LLhlmeia2HjolCRbB9EKrMEfb
JWT_EXPIRES_IN=7d
# ========== Apple IAP 配置 ==========
# Apple Shared Secret从 App Store Connect 获取)
# 用于验证内购收据
# 获取路径App Store Connect -> 你的 App -> 内购 -> App 专用共享密钥
APPLE_SHARED_SECRET=请从AppStoreConnect获取并填写
# ========== Apple Sign In 配置 ==========
# Apple Client ID通常是你的 Bundle ID
# iOS App Bundle ID: com.mustmaster.WildGrowth
APPLE_CLIENT_ID=com.mustmaster.WildGrowth
# 注意iOS App 使用 Sign in with Apple 时,主要验证 identityToken
# 不需要配置 APPLE_TEAM_ID 和 APPLE_KEY_ID这些用于 Web 登录)
# ========== 日志配置 ==========
LOG_LEVEL=info
# ========== CORS 配置(可选)==========
# 如果需要限制跨域访问,可以设置具体的域名
# 例如CORS_ORIGIN=https://muststudy.xin,https://api.muststudy.xin
# 留空则允许所有来源(开发阶段)
CORS_ORIGIN=
# ========== 文件上传配置 ==========
# 图片上传最大文件大小(字节),默认 2MB
MAX_FILE_SIZE=2097152
# ========== 域名配置(用于生成完整 URL==========
# 换域时:改 SERVER_URL 或 API_BASE_URL 其一即可SERVER_URL 优先)
# 管理后台会按「当前访问的域名」自动请求 API无需改前端
SERVER_URL=https://api.muststudy.xin
# 与 SERVER_URL 同义,二选一即可
API_BASE_URL=https://api.muststudy.xin
# ========== 阿里云号码认证服务配置 ==========
# AccessKey ID从阿里云控制台获取
ALIYUN_ACCESS_KEY_ID=你的AccessKey ID
# AccessKey Secret从阿里云控制台获取
ALIYUN_ACCESS_KEY_SECRET=你的AccessKey Secret
# 号码认证服务 - 系统赠送的签名名称
# 可选:速通互联验证码、云渚科技验证平台、速通互联验证平台 等
ALIYUN_PHONE_VERIFY_SIGN_NAME=速通互联验证码
# 号码认证服务 - 系统赠送的模板代码
# 登录/注册模板100001
# 修改绑定手机号模板100002
# 重置密码模板100003
# 绑定新手机号模板100004
# 验证绑定手机号模板100005
ALIYUN_PHONE_VERIFY_TEMPLATE_CODE=100001
# ========== Redis 配置(用于存储验证码)==========
# Redis 连接 URL可选如果不配置将使用内存存储
# 格式redis://[:password@]host[:port][/db-number]
# 例如redis://localhost:6379 或 redis://:password@localhost:6379/0
# 如果不配置,将使用内存存储(仅用于开发,生产环境建议使用 Redis
REDIS_URL=

29
backend/jest.config.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
transformIgnorePatterns: [
'node_modules/(?!(uuid)/)',
],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts',
],
coverageDirectory: 'coverage',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
testTimeout: 30000, // 30秒超时用于AI和向量化测试
extensionsToTreatAsEsm: ['.ts'],
globals: {
'ts-jest': {
useESM: false,
},
},
};

10899
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

86
backend/package.json Normal file
View File

@ -0,0 +1,86 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"prisma:seed": "ts-node prisma/seed.ts",
"generate-covers": "ts-node scripts/generate-missing-covers.ts",
"backfill-banner-watermark": "ts-node scripts/backfill-banner-watermark.ts",
"backfill-theme-colors": "ts-node scripts/backfill-theme-colors.ts",
"test:chat-vs-completions": "ts-node scripts/test-chat-vs-completions.ts",
"build:prod": "npm run prisma:generate && npm run build",
"deploy:prod": "npm run build:prod && npm run prisma:migrate:deploy",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:unit": "jest --testPathPatterns=__tests__/services",
"test:integration": "jest --testPathPatterns=__tests__/integration",
"test:call-records": "node scripts/verify-call-records-api.js",
"playground:xhs-cover": "ts-node scripts/xhs-cover-playground.ts",
"playground:xhs-generate": "ts-node scripts/generate-xhs-cover-assets.ts",
"playground": "npm run playground:xhs-generate && npm run dev"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@alicloud/pop-core": "^1.8.0",
"@prisma/client": "^6.19.0",
"@types/multer": "^2.0.0",
"@types/uuid": "^10.0.0",
"@xenova/transformers": "^2.17.2",
"ali-oss": "^6.23.0",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"canvas": "^3.2.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"epub": "^1.3.0",
"express": "^5.2.1",
"express-rate-limit": "^7.5.1",
"joi": "^18.0.2",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.2.0",
"mammoth": "^1.11.0",
"multer": "^2.0.2",
"openai": "^6.16.0",
"pdf-parse": "^1.1.1",
"prisma": "^6.19.0",
"uuid": "^13.0.0",
"winston": "^3.18.3"
},
"devDependencies": {
"@types/ali-oss": "^6.23.2",
"@types/axios": "^0.9.36",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1",
"@types/pdf-parse": "^1.1.5",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
"jest": "^30.2.0",
"nodemon": "^3.1.11",
"prettier": "^3.7.4",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,151 @@
-- 添加 2 个小节课测试数据
-- 执行方式psql -U your_username -d your_database -f add_two_single_courses.sql
-- ============================================================
-- 小节课 15分钟时间管理
-- ============================================================
-- 插入小节课 1
INSERT INTO courses (id, title, type, total_nodes, created_at)
VALUES ('course_single_001', '5分钟时间管理', 'single', 1, NOW())
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
type = 'single',
total_nodes = EXCLUDED.total_nodes;
-- 插入对应的节点
INSERT INTO course_nodes (id, course_id, title, order_index, created_at)
VALUES ('node_single_001', 'course_single_001', '时间管理的核心原则', 0, NOW())
ON CONFLICT (id) DO UPDATE SET
course_id = EXCLUDED.course_id,
title = EXCLUDED.title,
order_index = EXCLUDED.order_index;
-- 为节点创建基础幻灯片4张幻灯片
INSERT INTO node_slides (id, node_id, slide_type, order_index, content, effect, created_at)
VALUES
(
'slide_single_001_01',
'node_single_001',
'text',
1,
'{"title": "5分钟时间管理", "paragraphs": ["欢迎学习时间管理核心原则", "让我们快速掌握高效的时间管理方法"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_001_02',
'node_single_001',
'text',
2,
'{"title": "核心原则", "paragraphs": ["1. 优先级排序:重要且紧急的事情优先", "2. 番茄工作法25分钟专注5分钟休息", "3. 时间块:为每个任务分配固定时间"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_001_03',
'node_single_001',
'text',
3,
'{"title": "实践要点", "paragraphs": ["每天早上列出今日最重要的3件事", "使用番茄钟保持专注", "每天晚上回顾完成情况"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_001_04',
'node_single_001',
'text',
4,
'{"title": "本节小结", "paragraphs": ["你已经完成了「时间管理的核心原则」的学习", "记住:高效的时间管理需要持续练习", "每天进步一点点,最终会带来巨大的改变"]}'::jsonb,
'fade_in',
NOW()
)
ON CONFLICT (id) DO UPDATE SET
slide_type = EXCLUDED.slide_type,
order_index = EXCLUDED.order_index,
content = EXCLUDED.content,
effect = EXCLUDED.effect;
-- ============================================================
-- 小节课 23分钟学会专注
-- ============================================================
-- 插入小节课 2
INSERT INTO courses (id, title, type, total_nodes, created_at)
VALUES ('course_single_002', '3分钟学会专注', 'single', 1, NOW())
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
type = 'single',
total_nodes = EXCLUDED.total_nodes;
-- 插入对应的节点
INSERT INTO course_nodes (id, course_id, title, order_index, created_at)
VALUES ('node_single_002', 'course_single_002', '专注力的训练方法', 0, NOW())
ON CONFLICT (id) DO UPDATE SET
course_id = EXCLUDED.course_id,
title = EXCLUDED.title,
order_index = EXCLUDED.order_index;
-- 为节点创建基础幻灯片4张幻灯片
INSERT INTO node_slides (id, node_id, slide_type, order_index, content, effect, created_at)
VALUES
(
'slide_single_002_01',
'node_single_002',
'text',
1,
'{"title": "3分钟学会专注", "paragraphs": ["欢迎学习专注力的训练方法", "让我们快速掌握提升专注力的技巧"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_002_02',
'node_single_002',
'text',
2,
'{"title": "专注的原理", "paragraphs": ["专注力是一种可以训练的能力", "大脑需要时间进入专注状态约15分钟", "减少干扰是提升专注的关键"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_002_03',
'node_single_002',
'text',
3,
'{"title": "实用技巧", "paragraphs": ["关闭所有通知和干扰源", "设置专门的专注时间和空间", "使用深呼吸帮助快速进入专注状态"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_002_04',
'node_single_002',
'text',
4,
'{"title": "本节小结", "paragraphs": ["你已经完成了「专注力的训练方法」的学习", "记住:专注力需要持续练习", "从每天15分钟开始逐步提升专注时长"]}'::jsonb,
'fade_in',
NOW()
)
ON CONFLICT (id) DO UPDATE SET
slide_type = EXCLUDED.slide_type,
order_index = EXCLUDED.order_index,
content = EXCLUDED.content,
effect = EXCLUDED.effect;
-- ============================================================
-- 验证数据
-- ============================================================
SELECT
c.id as course_id,
c.title as course_title,
c.type,
c.total_nodes,
n.id as node_id,
n.title as node_title,
n.order_index,
(SELECT COUNT(*) FROM node_slides WHERE node_id = n.id) as slide_count
FROM courses c
LEFT JOIN course_nodes n ON c.id = n.course_id
WHERE c.id IN ('course_single_001', 'course_single_002')
ORDER BY c.id, n.order_index;

View File

@ -0,0 +1,19 @@
-- 添加 type 字段到 courses 表
-- 如果字段已存在,不会报错(使用 IF NOT EXISTS
-- 检查并添加 type 字段
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'courses'
AND column_name = 'type'
) THEN
ALTER TABLE courses ADD COLUMN type TEXT NOT NULL DEFAULT 'system';
RAISE NOTICE 'Added type column to courses table';
ELSE
RAISE NOTICE 'type column already exists in courses table';
END IF;
END $$;

View File

@ -0,0 +1,161 @@
-- 添加竖屏课程测试数据
-- 执行方式psql -U your_username -d your_database -f add_vertical_screen_course.sql
-- 或者cd backend && psql $DATABASE_URL -f prisma/add_vertical_screen_course.sql
-- ============================================================
-- 竖屏课程:高效沟通的艺术
-- ============================================================
-- 插入竖屏课程
INSERT INTO courses (id, title, subtitle, description, type, total_nodes, created_at)
VALUES (
'course_vertical_001',
'高效沟通的艺术',
'掌握职场沟通的核心技巧',
'通过真实案例和实用方法,帮助你提升沟通能力,在职场中更游刃有余。',
'vertical_screen',
3,
NOW()
)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
subtitle = EXCLUDED.subtitle,
description = EXCLUDED.description,
type = 'vertical_screen',
total_nodes = EXCLUDED.total_nodes;
-- ============================================================
-- 小节 1倾听的艺术
-- ============================================================
-- 插入节点 1
INSERT INTO course_nodes (id, course_id, title, subtitle, order_index, duration, created_at)
VALUES (
'node_vertical_001_01',
'course_vertical_001',
'倾听的艺术',
'学会真正听懂对方',
0,
8,
NOW()
)
ON CONFLICT (id) DO UPDATE SET
course_id = EXCLUDED.course_id,
title = EXCLUDED.title,
subtitle = EXCLUDED.subtitle,
order_index = EXCLUDED.order_index,
duration = EXCLUDED.duration;
-- 为节点 1 创建富文本内容(竖屏课程使用 rich_text 字段)
INSERT INTO node_slides (id, node_id, slide_type, order_index, content, effect, created_at)
VALUES (
'slide_vertical_001_01',
'node_vertical_001_01',
'text',
0,
'{"rich_text": "<h1>倾听的艺术</h1><p>真正的沟通不是说话,而是倾听。学会倾听,是高效沟通的第一步。</p><h2>为什么倾听如此重要?</h2><p>很多人认为沟通就是表达自己的观点,但实际上,<span class=\"highlight\">倾听才是沟通的核心</span>。只有真正听懂对方,才能做出有效的回应。</p><h2>倾听的三个层次</h2><p><strong>第一层:听到</strong> - 你听到了对方的声音,但可能没有理解。</p><p><strong>第二层:听懂</strong> - 你理解了对方说的内容,知道了表面意思。</p><p><strong>第三层:听透</strong> - 你理解了对方的情绪、需求和背后的真实意图。</p><h2>如何提升倾听能力?</h2><p>1. 保持专注,避免分心</p><p>2. 用眼神和肢体语言表达关注</p><p>3. 不打断对方,让对方说完</p><p>4. 用提问确认理解,而不是急于回应</p><p>5. 关注对方的情绪,而不只是内容</p>"}'::jsonb,
'fade_in',
NOW()
)
ON CONFLICT (id) DO UPDATE SET
slide_type = EXCLUDED.slide_type,
order_index = EXCLUDED.order_index,
content = EXCLUDED.content,
effect = EXCLUDED.effect;
-- ============================================================
-- 小节 2表达的技巧
-- ============================================================
-- 插入节点 2
INSERT INTO course_nodes (id, course_id, title, subtitle, order_index, duration, created_at)
VALUES (
'node_vertical_001_02',
'course_vertical_001',
'表达的技巧',
'让你的话更有说服力',
1,
10,
NOW()
)
ON CONFLICT (id) DO UPDATE SET
course_id = EXCLUDED.course_id,
title = EXCLUDED.title,
subtitle = EXCLUDED.subtitle,
order_index = EXCLUDED.order_index,
duration = EXCLUDED.duration;
-- 为节点 2 创建富文本内容
INSERT INTO node_slides (id, node_id, slide_type, order_index, content, effect, created_at)
VALUES (
'slide_vertical_001_02',
'node_vertical_001_02',
'text',
0,
'{"rich_text": "<h1>表达的技巧</h1><p>清晰、有力的表达能让你的观点更容易被接受。掌握表达的技巧,让沟通更高效。</p><h2>结构化表达</h2><p>好的表达需要清晰的结构。推荐使用<span class=\"highlight\">金字塔原理</span>:先结论,后原因,再案例。</p><p><strong>结论先行</strong> - 先说你的核心观点</p><p><strong>分层说明</strong> - 用3个要点支撑你的观点</p><p><strong>案例佐证</strong> - 用具体案例让观点更有说服力</p><h2>语言的力量</h2><p>用词的选择会直接影响沟通效果:</p><p>❌ \"我觉得可能这样会好一点\"</p><p>✅ \"我建议采用这个方案,原因有三点\"</p><p>用肯定的语言替代模糊的表达,会让你的观点更可信。</p><h2>非语言沟通</h2><p>除了语言,肢体语言也至关重要:</p><p>• <strong>眼神接触</strong> - 保持适度的眼神交流,表达自信</p><p>• <strong>姿态</strong> - 保持开放的身体姿态,不要交叉手臂</p><p>• <strong>语速</strong> - 控制语速,重要内容可以放慢强调</p><p>• <strong>手势</strong> - 适度的手势能增强表达力</p>"}'::jsonb,
'fade_in',
NOW()
)
ON CONFLICT (id) DO UPDATE SET
slide_type = EXCLUDED.slide_type,
order_index = EXCLUDED.order_index,
content = EXCLUDED.content,
effect = EXCLUDED.effect;
-- ============================================================
-- 小节 3冲突的处理
-- ============================================================
-- 插入节点 3
INSERT INTO course_nodes (id, course_id, title, subtitle, order_index, duration, created_at)
VALUES (
'node_vertical_001_03',
'course_vertical_001',
'冲突的处理',
'在分歧中寻找共识',
2,
12,
NOW()
)
ON CONFLICT (id) DO UPDATE SET
course_id = EXCLUDED.course_id,
title = EXCLUDED.title,
subtitle = EXCLUDED.subtitle,
order_index = EXCLUDED.order_index,
duration = EXCLUDED.duration;
-- 为节点 3 创建富文本内容
INSERT INTO node_slides (id, node_id, slide_type, order_index, content, effect, created_at)
VALUES (
'slide_vertical_001_03',
'node_vertical_001_03',
'text',
0,
'{"rich_text": "<h1>冲突的处理</h1><p>冲突是沟通中不可避免的。关键在于如何将冲突转化为建设性的对话。</p><h2>理解冲突的本质</h2><p>大多数冲突不是观点的对立,而是<span class=\"highlight\">需求的不匹配</span>。找到双方的真实需求,是解决冲突的关键。</p><p>冲突通常源于:</p><p>• 利益的不一致</p><p>• 价值观的差异</p><p>• 沟通的误解</p><p>• 情绪的干扰</p><h2>处理冲突的三步法</h2><p><strong>第一步:冷静下来</strong></p><p>情绪激动时不要沟通。给自己和对方一些时间,等情绪平复后再讨论。</p><p><strong>第二步:理解对方</strong></p><p>尝试站在对方的角度思考:\"如果我是他,为什么会这样想?\"理解对方的立场和需求。</p><p><strong>第三步:寻找共赢</strong></p><p>不要只想着\"我赢\",而是寻找\"我们都赢\"的解决方案。通常有第三种选择比妥协更好。</p><h2>实用技巧</h2><p>• 使用\"我\"的表达方式,而不是\"你\"\"我感到...\" 而不是 \"你总是...\"</p><p>• 关注问题本身,而不是攻击对方</p><p>• 承认对方的感受:\"我理解你的感受\"</p><p>• 寻找共同目标:\"我们都是为了...\"</p><p>• 如果无法解决,可以暂时搁置,之后再讨论</p>"}'::jsonb,
'fade_in',
NOW()
)
ON CONFLICT (id) DO UPDATE SET
slide_type = EXCLUDED.slide_type,
order_index = EXCLUDED.order_index,
content = EXCLUDED.content,
effect = EXCLUDED.effect;
-- ============================================================
-- 验证数据
-- ============================================================
SELECT
c.id as course_id,
c.title as course_title,
c.type,
c.total_nodes,
n.id as node_id,
n.title as node_title,
n.order_index,
n.duration,
(SELECT COUNT(*) FROM node_slides WHERE node_id = n.id) as slide_count
FROM courses c
LEFT JOIN course_nodes n ON c.id = n.course_id
WHERE c.id = 'course_vertical_001'
ORDER BY n.order_index;

View File

@ -0,0 +1,152 @@
-- 在服务器上插入小节课测试数据
-- 执行方式psql -U your_username -d your_database -f insert-single-courses-server.sql
-- 或者在服务器上psql $DATABASE_URL -f insert-single-courses-server.sql
-- ============================================================
-- 小节课 15分钟时间管理
-- ============================================================
-- 插入小节课 1
INSERT INTO courses (id, title, type, total_nodes, created_at)
VALUES ('course_single_001', '5分钟时间管理', 'single', 1, NOW())
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
type = 'single',
total_nodes = EXCLUDED.total_nodes;
-- 插入对应的节点
INSERT INTO course_nodes (id, course_id, title, order_index, created_at)
VALUES ('node_single_001', 'course_single_001', '时间管理的核心原则', 0, NOW())
ON CONFLICT (id) DO UPDATE SET
course_id = EXCLUDED.course_id,
title = EXCLUDED.title,
order_index = EXCLUDED.order_index;
-- 为节点创建基础幻灯片4张幻灯片
INSERT INTO node_slides (id, node_id, slide_type, order_index, content, effect, created_at)
VALUES
(
'slide_single_001_01',
'node_single_001',
'text',
1,
'{"title": "5分钟时间管理", "paragraphs": ["欢迎学习时间管理核心原则", "让我们快速掌握高效的时间管理方法"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_001_02',
'node_single_001',
'text',
2,
'{"title": "核心原则", "paragraphs": ["1. 优先级排序:重要且紧急的事情优先", "2. 番茄工作法25分钟专注5分钟休息", "3. 时间块:为每个任务分配固定时间"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_001_03',
'node_single_001',
'text',
3,
'{"title": "实践要点", "paragraphs": ["每天早上列出今日最重要的3件事", "使用番茄钟保持专注", "每天晚上回顾完成情况"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_001_04',
'node_single_001',
'text',
4,
'{"title": "本节小结", "paragraphs": ["你已经完成了「时间管理的核心原则」的学习", "记住:高效的时间管理需要持续练习", "每天进步一点点,最终会带来巨大的改变"]}'::jsonb,
'fade_in',
NOW()
)
ON CONFLICT (id) DO UPDATE SET
slide_type = EXCLUDED.slide_type,
order_index = EXCLUDED.order_index,
content = EXCLUDED.content,
effect = EXCLUDED.effect;
-- ============================================================
-- 小节课 23分钟学会专注
-- ============================================================
-- 插入小节课 2
INSERT INTO courses (id, title, type, total_nodes, created_at)
VALUES ('course_single_002', '3分钟学会专注', 'single', 1, NOW())
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
type = 'single',
total_nodes = EXCLUDED.total_nodes;
-- 插入对应的节点
INSERT INTO course_nodes (id, course_id, title, order_index, created_at)
VALUES ('node_single_002', 'course_single_002', '专注力的训练方法', 0, NOW())
ON CONFLICT (id) DO UPDATE SET
course_id = EXCLUDED.course_id,
title = EXCLUDED.title,
order_index = EXCLUDED.order_index;
-- 为节点创建基础幻灯片4张幻灯片
INSERT INTO node_slides (id, node_id, slide_type, order_index, content, effect, created_at)
VALUES
(
'slide_single_002_01',
'node_single_002',
'text',
1,
'{"title": "3分钟学会专注", "paragraphs": ["欢迎学习专注力的训练方法", "让我们快速掌握提升专注力的技巧"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_002_02',
'node_single_002',
'text',
2,
'{"title": "专注的原理", "paragraphs": ["专注力是一种可以训练的能力", "大脑需要时间进入专注状态约15分钟", "减少干扰是提升专注的关键"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_002_03',
'node_single_002',
'text',
3,
'{"title": "实用技巧", "paragraphs": ["关闭所有通知和干扰源", "设置专门的专注时间和空间", "使用深呼吸帮助快速进入专注状态"]}'::jsonb,
'fade_in',
NOW()
),
(
'slide_single_002_04',
'node_single_002',
'text',
4,
'{"title": "本节小结", "paragraphs": ["你已经完成了「专注力的训练方法」的学习", "记住:专注力需要持续练习", "从每天15分钟开始逐步提升专注时长"]}'::jsonb,
'fade_in',
NOW()
)
ON CONFLICT (id) DO UPDATE SET
slide_type = EXCLUDED.slide_type,
order_index = EXCLUDED.order_index,
content = EXCLUDED.content,
effect = EXCLUDED.effect;
-- ============================================================
-- 验证数据
-- ============================================================
SELECT
c.id as course_id,
c.title as course_title,
c.type,
c.total_nodes,
n.id as node_id,
n.title as node_title,
n.order_index,
(SELECT COUNT(*) FROM node_slides WHERE node_id = n.id) as slide_count
FROM courses c
LEFT JOIN course_nodes n ON c.id = n.course_id
WHERE c.id IN ('course_single_001', 'course_single_002')
ORDER BY c.id, n.order_index;

View File

@ -0,0 +1,271 @@
import { PrismaClient } from '@prisma/client';
import dotenv from 'dotenv';
// 加载环境变量
dotenv.config();
const prisma = new PrismaClient();
async function main() {
console.log('🌱 开始插入小节课测试数据...');
try {
// ============================================================
// 小节课 15分钟时间管理
// ============================================================
const course1 = await prisma.course.upsert({
where: { id: 'course_single_001' },
update: {
title: '5分钟时间管理',
type: 'single',
totalNodes: 1,
},
create: {
id: 'course_single_001',
title: '5分钟时间管理',
type: 'single',
totalNodes: 1,
},
});
const node1 = await prisma.courseNode.upsert({
where: { id: 'node_single_001' },
update: {
courseId: course1.id,
title: '时间管理的核心原则',
orderIndex: 0,
},
create: {
id: 'node_single_001',
courseId: course1.id,
title: '时间管理的核心原则',
orderIndex: 0,
},
});
// 删除现有幻灯片(如果存在)
await prisma.nodeSlide.deleteMany({
where: { nodeId: node1.id },
});
// 创建幻灯片
const slides1 = [
{
id: 'slide_single_001_01',
nodeId: node1.id,
slideType: 'text',
orderIndex: 1,
content: {
title: '5分钟时间管理',
paragraphs: [
'欢迎学习时间管理核心原则',
'让我们快速掌握高效的时间管理方法',
],
},
effect: 'fade_in',
},
{
id: 'slide_single_001_02',
nodeId: node1.id,
slideType: 'text',
orderIndex: 2,
content: {
title: '核心原则',
paragraphs: [
'1. 优先级排序:重要且紧急的事情优先',
'2. 番茄工作法25分钟专注5分钟休息',
'3. 时间块:为每个任务分配固定时间',
],
},
effect: 'fade_in',
},
{
id: 'slide_single_001_03',
nodeId: node1.id,
slideType: 'text',
orderIndex: 3,
content: {
title: '实践要点',
paragraphs: [
'每天早上列出今日最重要的3件事',
'使用番茄钟保持专注',
'每天晚上回顾完成情况',
],
},
effect: 'fade_in',
},
{
id: 'slide_single_001_04',
nodeId: node1.id,
slideType: 'text',
orderIndex: 4,
content: {
title: '本节小结',
paragraphs: [
'你已经完成了「时间管理的核心原则」的学习',
'记住:高效的时间管理需要持续练习',
'每天进步一点点,最终会带来巨大的改变',
],
},
effect: 'fade_in',
},
];
for (const slide of slides1) {
await prisma.nodeSlide.upsert({
where: { id: slide.id },
update: slide,
create: slide,
});
}
console.log('✅ 小节课 1 创建成功5分钟时间管理');
// ============================================================
// 小节课 23分钟学会专注
// ============================================================
const course2 = await prisma.course.upsert({
where: { id: 'course_single_002' },
update: {
title: '3分钟学会专注',
type: 'single',
totalNodes: 1,
},
create: {
id: 'course_single_002',
title: '3分钟学会专注',
type: 'single',
totalNodes: 1,
},
});
const node2 = await prisma.courseNode.upsert({
where: { id: 'node_single_002' },
update: {
courseId: course2.id,
title: '专注力的训练方法',
orderIndex: 0,
},
create: {
id: 'node_single_002',
courseId: course2.id,
title: '专注力的训练方法',
orderIndex: 0,
},
});
// 删除现有幻灯片(如果存在)
await prisma.nodeSlide.deleteMany({
where: { nodeId: node2.id },
});
// 创建幻灯片
const slides2 = [
{
id: 'slide_single_002_01',
nodeId: node2.id,
slideType: 'text',
orderIndex: 1,
content: {
title: '3分钟学会专注',
paragraphs: [
'欢迎学习专注力的训练方法',
'让我们快速掌握提升专注力的技巧',
],
},
effect: 'fade_in',
},
{
id: 'slide_single_002_02',
nodeId: node2.id,
slideType: 'text',
orderIndex: 2,
content: {
title: '专注的原理',
paragraphs: [
'专注力是一种可以训练的能力',
'大脑需要时间进入专注状态约15分钟',
'减少干扰是提升专注的关键',
],
},
effect: 'fade_in',
},
{
id: 'slide_single_002_03',
nodeId: node2.id,
slideType: 'text',
orderIndex: 3,
content: {
title: '实用技巧',
paragraphs: [
'关闭所有通知和干扰源',
'设置专门的专注时间和空间',
'使用深呼吸帮助快速进入专注状态',
],
},
effect: 'fade_in',
},
{
id: 'slide_single_002_04',
nodeId: node2.id,
slideType: 'text',
orderIndex: 4,
content: {
title: '本节小结',
paragraphs: [
'你已经完成了「专注力的训练方法」的学习',
'记住:专注力需要持续练习',
'从每天15分钟开始逐步提升专注时长',
],
},
effect: 'fade_in',
},
];
for (const slide of slides2) {
await prisma.nodeSlide.upsert({
where: { id: slide.id },
update: slide,
create: slide,
});
}
console.log('✅ 小节课 2 创建成功3分钟学会专注');
// 验证数据
const courses = await prisma.course.findMany({
where: { type: 'single' },
include: {
nodes: {
include: {
slides: true,
},
},
},
});
console.log('\n📊 验证数据:');
for (const course of courses) {
console.log(`\n课程${course.title} (${course.id})`);
for (const node of course.nodes) {
console.log(` - 节点:${node.title} (${node.id})`);
console.log(` - 幻灯片数量:${node.slides.length}`);
}
}
console.log('\n🎉 小节课测试数据插入完成!');
} catch (error) {
console.error('❌ 插入数据失败:', error);
throw error;
}
}
main()
.catch((e) => {
console.error('❌ 执行失败:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,377 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
phone String? @unique
appleId String? @unique @map("apple_id")
nickname String?
avatar String?
digitalId String? @unique @map("digital_id") // ✅ 赛博学习证ID (Wild ID)
agreementAccepted Boolean @default(false) @map("agreement_accepted")
isPro Boolean @default(false) @map("is_pro") // 是否为付费会员
proExpireDate DateTime? @map("pro_expire_date") // 会员过期时间(可选,预留给订阅制)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
settings UserSettings?
learningProgress UserLearningProgress[]
achievements UserAchievement[]
courses UserCourse[]
notes Note[]
notebooks Notebook[]
createdCourses Course[] @relation("CreatedCourses") // ✅ 新增:用户创建的课程
generationTasks CourseGenerationTask[] // ✅ AI 课程生成任务
@@map("users")
}
model UserSettings {
userId String @id @map("user_id")
pushNotification Boolean @default(true) @map("push_notification")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_settings")
}
model Course {
id String @id @default(uuid())
title String
subtitle String? // 课程副标题
description String?
coverImage String? @map("cover_image")
themeColor String? @map("theme_color") // ✅ 新增:主题色 Hex如 "#2266FF"
watermarkIcon String? @map("watermark_icon") // ✅ 新增水印图标名称SF Symbol如 "book.closed.fill"
type String @default("system") // ✅ 简化:所有课程统一为 system竖屏课程
status String @default("published") // ✅ 新增published | draft | test_published
minAppVersion String? @map("min_app_version") // ✅ 新增最低App版本号如 "1.0.0"null表示所有版本可见
isPortrait Boolean @default(true) @map("is_portrait") // ✅ 简化:所有课程都是竖屏,默认值改为 true
deletedAt DateTime? @map("deleted_at") // ✅ 新增:软删除时间戳
totalNodes Int @default(0) @map("total_nodes")
// ✅ 创作者和可见范围
createdBy String? @map("created_by") // null = 系统创建,有值 = 用户ID
visibility String @default("private") @map("visibility") // "public" | "private"
createdAsDraft Boolean @default(false) @map("created_as_draft") // 后台 AI 创建为草稿时 true完成逻辑据此跳过自动发布避免多查 Task 表
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// ✅ 续旧课链路
parentCourseId String? @map("parent_course_id") // 续旧课时指向父课程
accumulatedSummary String? @map("accumulated_summary") // 累积知识点摘要≤1000字
creator User? @relation("CreatedCourses", fields: [createdBy], references: [id], onDelete: SetNull)
parentCourse Course? @relation("CourseContinuation", fields: [parentCourseId], references: [id], onDelete: SetNull)
childCourses Course[] @relation("CourseContinuation")
chapters CourseChapter[]
nodes CourseNode[]
userCourses UserCourse[]
notes Note[]
generationTask CourseGenerationTask? // ✅ AI 生成任务关联
operationalBannerCourses OperationalBannerCourse[]
@@index([createdBy])
@@index([visibility])
@@index([parentCourseId])
@@map("courses")
}
// 发现页运营位软删除orderIndex 从 1
model OperationalBanner {
id String @id @default(uuid())
title String
orderIndex Int @default(1) @map("order_index")
isEnabled Boolean @default(true) @map("is_enabled")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
courses OperationalBannerCourse[]
@@index([deletedAt, isEnabled])
@@map("operational_banners")
}
// 运营位-课程关联(每运营位最多 10 门课,业务层校验)
model OperationalBannerCourse {
id String @id @default(uuid())
bannerId String @map("banner_id")
courseId String @map("course_id")
orderIndex Int @default(1) @map("order_index")
createdAt DateTime @default(now()) @map("created_at")
banner OperationalBanner @relation(fields: [bannerId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([bannerId, courseId])
@@index([bannerId])
@@map("operational_banner_courses")
}
// ✅ AI 课程生成任务表
model CourseGenerationTask {
id String @id @default(uuid())
courseId String @unique @map("course_id")
userId String @map("user_id")
sourceText String @map("source_text")
sourceType String? @map("source_type") // ✅ 新增direct | document | continue
persona String? @map("persona") // ✅ 新增architect | muse | hacker
mode String? // "detailed" | "essence"
modelProvider String @default("doubao") @map("model_provider")
status String @default("pending") // pending | mode_selected | outline_generating | outline_completed | content_generating | completed | failed
progress Int @default(0) // 0-100
errorMessage String? @map("error_message")
outline Json? // 生成的大纲JSON格式
currentStep String? @map("current_step") // 当前步骤outline | content | node_xxx
saveAsDraft Boolean @default(false) @map("save_as_draft") // 后台创建:生成完成后不自动发布、不自动加入 UserCourse
promptSent String? @map("prompt_sent") // 当时发给模型的真实提示词fire-and-forget 写入,供调用记录查看)
modelId String? @map("model_id") // 本次任务实际使用的模型 ID如 doubao-seed-1-6-flash-250828 / doubao-seed-1-6-lite-251015供调用记录详情展示
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([status])
@@map("course_generation_tasks")
}
model CourseChapter {
id String @id @default(uuid())
courseId String @map("course_id")
title String
orderIndex Int @map("order_index")
createdAt DateTime @default(now()) @map("created_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
nodes CourseNode[]
@@unique([courseId, orderIndex])
@@map("course_chapters")
}
model CourseNode {
id String @id @default(uuid())
courseId String @map("course_id")
chapterId String? @map("chapter_id") // 可选,支持无章节的节点
title String
subtitle String?
orderIndex Int @map("order_index")
duration Int? // 预估时长(分钟)
unlockCondition String? @map("unlock_condition") // 解锁条件
createdAt DateTime @default(now()) @map("created_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
chapter CourseChapter? @relation(fields: [chapterId], references: [id], onDelete: SetNull)
slides NodeSlide[]
learningProgress UserLearningProgress[]
notes Note[]
@@unique([courseId, orderIndex])
@@map("course_nodes")
}
model NodeSlide {
id String @id @default(uuid())
nodeId String @map("node_id")
slideType String @map("slide_type") // text | image | quiz | interactive
orderIndex Int @map("order_index")
content Json // 存储卡片内容(灵活结构)
effect String? // fade_in | typewriter | slide_up
interaction String? // tap_to_reveal | zoom | parallax
createdAt DateTime @default(now()) @map("created_at")
node CourseNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@map("node_slides")
}
model UserLearningProgress {
id String @id @default(uuid())
userId String @map("user_id")
nodeId String @map("node_id")
status String @default("not_started") // not_started | in_progress | completed
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
totalStudyTime Int @default(0) @map("total_study_time") // 总学习时长(秒)
currentSlide Int @default(0) @map("current_slide") // 当前学习到的幻灯片位置
completionRate Int @default(0) @map("completion_rate") // 完成度(%
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
node CourseNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@unique([userId, nodeId])
@@map("user_learning_progress")
}
model UserAchievement {
id String @id @default(uuid())
userId String @map("user_id")
achievementType String @map("achievement_type") // lesson_completed | course_completed
achievementData Json @map("achievement_data") // 存储成就详情
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_achievements")
}
model UserCourse {
id String @id @default(uuid())
userId String @map("user_id")
courseId String @map("course_id")
lastOpenedAt DateTime? @map("last_opened_at") // ✅ 新增:最近打开时间,用于排序
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
@@map("user_courses")
}
// ✅ Phase 1: 新增 Notebook 模型
model Notebook {
id String @id @default(uuid())
userId String @map("user_id")
title String // 笔记本名称1-50字符
description String? // 描述可选0-200字符
coverImage String? @map("cover_image") // 封面图片,可选
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notes Note[]
@@index([userId])
@@map("notebooks")
}
// ✅ Phase 1: 扩展 Note 模型,支持笔记本和层级结构
model Note {
id String @id @default(uuid())
userId String @map("user_id")
// ✅ 新增:笔记本和层级字段
notebookId String? @map("notebook_id") // 所属笔记本 IDnil 表示未分类
parentId String? @map("parent_id") // 父笔记 IDnil 表示顶级
order Int @default(0) // 同级排序0, 1, 2...
level Int @default(0) // 层级深度0=顶级, 1=二级, 2=三级
// ✅ 修改:这些字段改为可选(支持独立笔记)
courseId String? @map("course_id")
nodeId String? @map("node_id")
startIndex Int? @map("start_index") // 全局 NSRange.location相对于合并后的全文
length Int? // 全局 NSRange.length
type String // highlight | thought | comment未来扩展
content String // 笔记内容(想法笔记的内容,或划线笔记的备注)
quotedText String? @map("quoted_text") // ✅ 修改:改为可选,独立笔记为 null
style String? // 样式(如 "yellow", "purple", "underline" 等,未来扩展)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade) // ✅ 重要Cascade 删除
course Course? @relation(fields: [courseId], references: [id], onDelete: Cascade)
node CourseNode? @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([userId, courseId])
@@index([userId, nodeId])
@@index([courseId, nodeId])
@@index([userId, notebookId]) // ✅ 新增:按笔记本查询
@@index([notebookId, parentId]) // ✅ 新增:按父笔记查询
@@index([notebookId, order]) // ✅ 新增:按排序查询
@@map("notes")
}
// 应用配置(如书籍解析 Prompt 等可运维修改的文案)
model AppConfig {
id String @id @default(uuid())
key String @unique
value String @db.Text
updatedAt DateTime @updatedAt @map("updated_at")
@@map("app_config")
}
// ✅ AI 提示词配置表提示词管理2.0
model AiPromptConfig {
id String @id @default(uuid())
promptType String @unique @map("prompt_type") // summary | outline | outline-essence | outline-detailed | content
promptTitle String? @map("prompt_title") // 提示词标题
description String? @db.Text // 描述
systemPrompt String @db.Text @map("system_prompt") // 系统提示词(可编辑)
userPromptTemplate String @db.Text @map("user_prompt_template") // 用户提示词模板(只读展示)
variables Json? // 变量说明 [{name, description, required, example}]
temperature Float @default(0.7) // 温度参数
maxTokens Int? @map("max_tokens") // 最大token数
topP Float? @map("top_p") // Top P 参数
enabled Boolean @default(true) // 是否启用
version Int @default(1) // 版本号
isDefault Boolean @default(false) @map("is_default") // 是否为默认配置
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([promptType])
@@map("ai_prompt_configs")
}
// 书籍解析 AI 调用日志(仅后台查看,不影响主流程)
model BookAiCallLog {
id String @id @default(uuid())
taskId String @map("task_id")
courseId String? @map("course_id")
chunkIndex Int? @map("chunk_index") // 切块时第几段0-basednull=单次
status String // success | failed
promptPreview String @map("prompt_preview") @db.Text // 前 2000 字
promptFull String @map("prompt_full") @db.Text // 完整,存时截断 500KB
responsePreview String? @map("response_preview") @db.Text // 前 5000 字
responseFull String? @map("response_full") @db.Text // 完整,存时截断 500KB
errorMessage String? @map("error_message") @db.Text
durationMs Int? @map("duration_ms")
createdAt DateTime @default(now()) @map("created_at")
@@index([taskId])
@@index([createdAt])
@@map("book_ai_call_logs")
}
// ✅ V1.0 埋点体系:轻量级自建事件追踪
model AnalyticsEvent {
id BigInt @id @default(autoincrement())
userId String? @map("user_id")
deviceId String @map("device_id")
sessionId String @map("session_id")
eventName String @map("event_name")
properties Json?
appVersion String? @map("app_version")
osVersion String? @map("os_version")
deviceModel String? @map("device_model")
networkType String? @map("network_type")
clientTs DateTime @map("client_ts")
serverTs DateTime @default(now()) @map("server_ts")
@@index([userId])
@@index([eventName])
@@index([clientTs])
@@index([sessionId])
@@map("analytics_events")
}
// ✅ AI 相关模型已删除

View File

@ -0,0 +1,158 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
phone String? @unique
appleId String? @unique @map("apple_id")
nickname String?
avatar String?
agreementAccepted Boolean @default(false) @map("agreement_accepted")
isPro Boolean @default(false) @map("is_pro") // 是否为付费会员
proExpireDate DateTime? @map("pro_expire_date") // 会员过期时间(可选,预留给订阅制)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
settings UserSettings?
learningProgress UserLearningProgress[]
achievements UserAchievement[]
courses UserCourse[]
@@map("users")
}
model UserSettings {
userId String @id @map("user_id")
pushNotification Boolean @default(true) @map("push_notification")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_settings")
}
model Course {
id String @id @default(uuid())
title String
subtitle String? // 课程副标题
description String?
coverImage String? @map("cover_image")
type String @default("system") // ✅ 新增system | single
status String @default("published") // ✅ 新增published | draft
deletedAt DateTime? @map("deleted_at") // ✅ 新增:软删除时间戳
totalNodes Int @default(0) @map("total_nodes")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
chapters CourseChapter[]
nodes CourseNode[]
userCourses UserCourse[]
@@map("courses")
}
model CourseChapter {
id String @id @default(uuid())
courseId String @map("course_id")
title String
orderIndex Int @map("order_index")
createdAt DateTime @default(now()) @map("created_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
nodes CourseNode[]
@@unique([courseId, orderIndex])
@@map("course_chapters")
}
model CourseNode {
id String @id @default(uuid())
courseId String @map("course_id")
chapterId String? @map("chapter_id") // 可选,支持无章节的节点
title String
subtitle String?
orderIndex Int @map("order_index")
duration Int? // 预估时长(分钟)
unlockCondition String? @map("unlock_condition") // 解锁条件
createdAt DateTime @default(now()) @map("created_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
chapter CourseChapter? @relation(fields: [chapterId], references: [id], onDelete: SetNull)
slides NodeSlide[]
learningProgress UserLearningProgress[]
@@unique([courseId, orderIndex])
@@map("course_nodes")
}
model NodeSlide {
id String @id @default(uuid())
nodeId String @map("node_id")
slideType String @map("slide_type") // text | image | quiz | interactive
orderIndex Int @map("order_index")
content Json // 存储卡片内容(灵活结构)
effect String? // fade_in | typewriter | slide_up
interaction String? // tap_to_reveal | zoom | parallax
createdAt DateTime @default(now()) @map("created_at")
node CourseNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@map("node_slides")
}
model UserLearningProgress {
id String @id @default(uuid())
userId String @map("user_id")
nodeId String @map("node_id")
status String @default("not_started") // not_started | in_progress | completed
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
totalStudyTime Int @default(0) @map("total_study_time") // 总学习时长(秒)
currentSlide Int @default(0) @map("current_slide") // 当前学习到的幻灯片位置
completionRate Int @default(0) @map("completion_rate") // 完成度(%
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
node CourseNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@unique([userId, nodeId])
@@map("user_learning_progress")
}
model UserAchievement {
id String @id @default(uuid())
userId String @map("user_id")
achievementType String @map("achievement_type") // lesson_completed | course_completed
achievementData Json @map("achievement_data") // 存储成就详情
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_achievements")
}
model UserCourse {
id String @id @default(uuid())
userId String @map("user_id")
courseId String @map("course_id")
lastOpenedAt DateTime? @map("last_opened_at") // ✅ 新增:最近打开时间,用于排序
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
@@map("user_courses")
}

699
backend/prisma/seed.ts Normal file
View File

@ -0,0 +1,699 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 开始创建测试用户数据...');
// 创建测试用户1非会员用户内购未解锁
const user1 = await prisma.user.upsert({
where: { phone: '13800000001' },
update: { isPro: false },
create: {
phone: '13800000001',
nickname: '测试用户1',
agreementAccepted: true,
isPro: false,
settings: {
create: {
pushNotification: true,
},
},
},
include: {
settings: true,
},
});
console.log('✅ 创建测试用户1非会员:', user1.id);
// 创建测试用户2非会员用户内购未解锁
const user2 = await prisma.user.upsert({
where: { phone: '13800000002' },
update: { isPro: false },
create: {
phone: '13800000002',
nickname: '测试用户2',
agreementAccepted: true,
isPro: false,
settings: {
create: {
pushNotification: true,
},
},
},
include: {
settings: true,
},
});
console.log('✅ 创建测试用户2非会员:', user2.id);
// 创建测试用户3非会员用户内购未解锁
const user3 = await prisma.user.upsert({
where: { phone: '13800000003' },
update: { isPro: false },
create: {
phone: '13800000003',
nickname: '测试用户3',
agreementAccepted: true,
isPro: false,
settings: {
create: {
pushNotification: true,
},
},
},
include: {
settings: true,
},
});
console.log('✅ 创建测试用户3非会员:', user3.id);
// 创建测试用户4非会员用户内购未解锁
const user4 = await prisma.user.upsert({
where: { phone: '13800000004' },
update: { isPro: false },
create: {
phone: '13800000004',
nickname: '测试用户4',
agreementAccepted: true,
isPro: false,
settings: {
create: {
pushNotification: true,
},
},
},
include: {
settings: true,
},
});
console.log('✅ 创建测试用户4非会员:', user4.id);
// ========== 清理所有课程数据 ==========
console.log('\n📚 开始创建课程测试数据...');
await prisma.nodeSlide.deleteMany({});
await prisma.userLearningProgress.deleteMany({});
await prisma.courseNode.deleteMany({});
await prisma.courseChapter.deleteMany({});
await prisma.course.deleteMany({});
// ========== 课程数据定义 ==========
const coursesData = [
{
id: 'course_001',
title: '认知觉醒',
subtitle: '开启你的元认知之旅',
description: '情绪急救与心理复原力构建。通过系统化的认知训练,帮助你重新认识大脑,掌握潜意识的智慧。',
coverImage: 'brain.head.profile',
chapters: [
{
id: 'c001_ch01',
title: '第一章:重新认识大脑',
nodes: [
{ id: 'c001_n01', title: '我们为什么会痛苦?', subtitle: '理解痛苦的根源', duration: 5 },
{ id: 'c001_n02', title: '大脑的节能机制', subtitle: '认识大脑的工作原理', duration: 6 },
{ id: 'c001_n03', title: '认知偏差的陷阱', subtitle: '识别认知陷阱', duration: 7 },
{ id: 'c001_n04', title: '情绪的本质', subtitle: '理解情绪机制', duration: 8 },
{ id: 'c001_n05', title: '思维的惯性', subtitle: '打破思维定式', duration: 9 },
],
},
{
id: 'c001_ch02',
title: '第二章:潜意识的智慧',
nodes: [
{ id: 'c001_n06', title: '潜意识的运作', subtitle: '探索潜意识', duration: 5 },
{ id: 'c001_n07', title: '直觉与理性', subtitle: '平衡直觉与理性', duration: 6 },
{ id: 'c001_n08', title: '内在对话', subtitle: '倾听内在声音', duration: 7 },
{ id: 'c001_n09', title: '自我觉察', subtitle: '培养觉察力', duration: 8 },
{ id: 'c001_n10', title: '潜意识的力量', subtitle: '释放潜意识', duration: 9 },
],
},
],
},
{
id: 'course_002',
title: '社会化指南',
subtitle: '从小镇i人到社交达人',
description: '专为内向女孩打造的社会化成长指南。从理解社交本质到建立自信,从克服社交恐惧到建立深度连接,一步步走出舒适圈。',
coverImage: 'person.2.fill',
chapters: [
{
id: 'c002_ch01',
title: '第一章:理解社交的本质',
nodes: [
{ id: 'c002_n01', title: '为什么社交让我感到疲惫?', subtitle: '理解内向者的社交特点', duration: 6 },
{ id: 'c002_n02', title: '社交不是表演,是连接', subtitle: '重新定义社交的意义', duration: 7 },
{ id: 'c002_n03', title: 'i人与e人的社交差异', subtitle: '认识自己的社交风格', duration: 5 },
{ id: 'c002_n04', title: '社交能量管理', subtitle: '如何保护自己的能量', duration: 6 },
{ id: 'c002_n05', title: '从被动到主动', subtitle: '改变社交心态', duration: 8 },
],
},
{
id: 'c002_ch02',
title: '第二章:克服社交恐惧',
nodes: [
{ id: 'c002_n06', title: '社交焦虑的根源', subtitle: '理解恐惧背后的心理', duration: 6 },
{ id: 'c002_n07', title: '小步快跑策略', subtitle: '渐进式社交练习', duration: 7 },
{ id: 'c002_n08', title: '准备你的社交工具箱', subtitle: '实用的社交技巧', duration: 8 },
{ id: 'c002_n09', title: '处理尴尬时刻', subtitle: '如何应对社交失误', duration: 6 },
{ id: 'c002_n10', title: '从失败中学习', subtitle: '把挫折变成成长', duration: 7 },
],
},
{
id: 'c002_ch03',
title: '第三章:建立深度连接',
nodes: [
{ id: 'c002_n11', title: '倾听的艺术', subtitle: '如何真正听懂别人', duration: 6 },
{ id: 'c002_n12', title: '分享你的故事', subtitle: '建立信任的技巧', duration: 7 },
{ id: 'c002_n13', title: '找到共同话题', subtitle: '如何开启对话', duration: 5 },
{ id: 'c002_n14', title: '维护长期关系', subtitle: '让友谊持续升温', duration: 8 },
{ id: 'c002_n15', title: '建立你的社交圈', subtitle: '从一个人到一群人', duration: 7 },
],
},
{
id: 'c002_ch04',
title: '第四章:在不同场景中社交',
nodes: [
{ id: 'c002_n16', title: '聚会中的i人', subtitle: '如何在聚会中自在', duration: 6 },
{ id: 'c002_n17', title: '一对一深度交流', subtitle: 'i人的优势场景', duration: 5 },
{ id: 'c002_n18', title: '网络社交的智慧', subtitle: '线上社交的技巧', duration: 7 },
{ id: 'c002_n19', title: '拒绝的艺术', subtitle: '保护自己的边界', duration: 6 },
{ id: 'c002_n20', title: '成为更好的自己', subtitle: '持续成长的力量', duration: 8 },
],
},
],
},
{
id: 'course_003',
title: '如何谈恋爱',
subtitle: 'i人女孩的恋爱成长课',
description: '从理解自己到理解对方,从建立关系到维护关系。专为内向女孩打造的恋爱指南,帮你找到属于自己的爱情节奏。',
coverImage: 'heart.fill',
chapters: [
{
id: 'c003_ch01',
title: '第一章:认识自己的恋爱模式',
nodes: [
{ id: 'c003_n01', title: 'i人在恋爱中的优势', subtitle: '发现你的独特魅力', duration: 6 },
{ id: 'c003_n02', title: '理解你的情感需求', subtitle: '什么让你感到被爱', duration: 7 },
{ id: 'c003_n03', title: '恋爱中的能量管理', subtitle: '如何保持平衡', duration: 5 },
{ id: 'c003_n04', title: '从暗恋到行动', subtitle: '如何表达你的心意', duration: 8 },
{ id: 'c003_n05', title: '识别对的人', subtitle: '找到适合你的伴侣', duration: 6 },
],
},
{
id: 'c003_ch02',
title: '第二章:建立连接',
nodes: [
{ id: 'c003_n06', title: '第一次约会指南', subtitle: '如何度过尴尬期', duration: 7 },
{ id: 'c003_n07', title: '深度对话的技巧', subtitle: '建立情感连接', duration: 6 },
{ id: 'c003_n08', title: '分享你的内心世界', subtitle: '如何打开心扉', duration: 8 },
{ id: 'c003_n09', title: '理解对方的信号', subtitle: '读懂他的心意', duration: 5 },
{ id: 'c003_n10', title: '处理不确定感', subtitle: '恋爱中的焦虑管理', duration: 7 },
],
},
{
id: 'c003_ch03',
title: '第三章:维护关系',
nodes: [
{ id: 'c003_n11', title: '沟通的艺术', subtitle: '如何表达你的需求', duration: 6 },
{ id: 'c003_n12', title: '处理冲突', subtitle: '当意见不合时', duration: 7 },
{ id: 'c003_n13', title: '保持独立空间', subtitle: '恋爱中的边界', duration: 5 },
{ id: 'c003_n14', title: '共同成长', subtitle: '让关系持续升温', duration: 8 },
{ id: 'c003_n15', title: '处理分手', subtitle: '如何优雅地告别', duration: 6 },
],
},
],
},
{
id: 'course_004',
title: '如何职场社交',
subtitle: 'i人女孩的职场生存指南',
description: '从面试到升职,从同事关系到领导沟通。帮助内向女孩在职场中找到自己的位置,建立专业形象,获得认可。',
coverImage: 'briefcase.fill',
chapters: [
{
id: 'c004_ch01',
title: '第一章职场中的i人优势',
nodes: [
{ id: 'c004_n01', title: '内向者的职场优势', subtitle: '发现你的独特价值', duration: 6 },
{ id: 'c004_n02', title: '建立专业形象', subtitle: '如何展现你的能力', duration: 7 },
{ id: 'c004_n03', title: '深度工作者的优势', subtitle: '专注力的力量', duration: 5 },
{ id: 'c004_n04', title: '倾听者的价值', subtitle: '如何成为好的倾听者', duration: 6 },
{ id: 'c004_n05', title: '从幕后到台前', subtitle: '如何展示你的成果', duration: 8 },
],
},
{
id: 'c004_ch02',
title: '第二章:建立职场关系',
nodes: [
{ id: 'c004_n06', title: '与同事建立信任', subtitle: '如何建立良好关系', duration: 6 },
{ id: 'c004_n07', title: '与领导沟通', subtitle: '如何向上管理', duration: 7 },
{ id: 'c004_n08', title: '参与团队讨论', subtitle: '如何在会议中发言', duration: 5 },
{ id: 'c004_n09', title: '建立你的职场网络', subtitle: '拓展人脉的技巧', duration: 8 },
{ id: 'c004_n10', title: '处理职场冲突', subtitle: '如何应对矛盾', duration: 6 },
],
},
{
id: 'c004_ch03',
title: '第三章:职场进阶',
nodes: [
{ id: 'c004_n11', title: '主动争取机会', subtitle: '如何表达你的意愿', duration: 7 },
{ id: 'c004_n12', title: '展示你的价值', subtitle: '让成果被看见', duration: 6 },
{ id: 'c004_n13', title: '建立个人品牌', subtitle: '打造你的专业形象', duration: 8 },
{ id: 'c004_n14', title: '处理职场压力', subtitle: '如何保持平衡', duration: 5 },
{ id: 'c004_n15', title: '持续成长', subtitle: '职场中的学习路径', duration: 7 },
],
},
{
id: 'c004_ch04',
title: '第四章:特殊场景应对',
nodes: [
{ id: 'c004_n16', title: '面试中的表现', subtitle: '如何展现你的优势', duration: 6 },
{ id: 'c004_n17', title: '公开演讲', subtitle: '克服演讲恐惧', duration: 7 },
{ id: 'c004_n18', title: '职场社交活动', subtitle: '如何在聚会中自在', duration: 5 },
{ id: 'c004_n19', title: '拒绝不合理要求', subtitle: '保护自己的边界', duration: 6 },
{ id: 'c004_n20', title: '成为职场中的自己', subtitle: '保持真实的自我', duration: 8 },
],
},
],
},
{
id: 'course_005',
title: '如何不焦虑',
subtitle: 'i人女孩的情绪管理课',
description: '从理解焦虑到管理情绪,从自我关怀到建立安全感。帮助内向女孩建立内心的平静,找到属于自己的节奏。',
coverImage: 'leaf.fill',
chapters: [
{
id: 'c005_ch01',
title: '第一章:理解焦虑',
nodes: [
{ id: 'c005_n01', title: '焦虑从何而来?', subtitle: '理解焦虑的根源', duration: 6 },
{ id: 'c005_n02', title: 'i人的焦虑特点', subtitle: '内向者的情绪模式', duration: 7 },
{ id: 'c005_n03', title: '过度思考的陷阱', subtitle: '如何停止内耗', duration: 5 },
{ id: 'c005_n04', title: '完美主义的负担', subtitle: '放下过高的期待', duration: 6 },
{ id: 'c005_n05', title: '社交焦虑的本质', subtitle: '理解你的恐惧', duration: 8 },
],
},
{
id: 'c005_ch02',
title: '第二章:情绪管理技巧',
nodes: [
{ id: 'c005_n06', title: '呼吸练习', subtitle: '快速缓解焦虑', duration: 5 },
{ id: 'c005_n07', title: '正念冥想', subtitle: '回到当下', duration: 7 },
{ id: 'c005_n08', title: '情绪日记', subtitle: '记录你的感受', duration: 6 },
{ id: 'c005_n09', title: '身体扫描', subtitle: '连接身体与情绪', duration: 5 },
{ id: 'c005_n10', title: '建立情绪工具箱', subtitle: '实用的情绪管理方法', duration: 8 },
],
},
{
id: 'c005_ch03',
title: '第三章:建立安全感',
nodes: [
{ id: 'c005_n11', title: '自我关怀', subtitle: '如何善待自己', duration: 6 },
{ id: 'c005_n12', title: '建立支持系统', subtitle: '找到你的后盾', duration: 7 },
{ id: 'c005_n13', title: '设定合理边界', subtitle: '保护你的能量', duration: 5 },
{ id: 'c005_n14', title: '接受不完美', subtitle: '允许自己犯错', duration: 6 },
{ id: 'c005_n15', title: '建立日常仪式', subtitle: '创造稳定感', duration: 8 },
],
},
{
id: 'c005_ch04',
title: '第四章:长期成长',
nodes: [
{ id: 'c005_n16', title: '改变思维模式', subtitle: '从消极到积极', duration: 7 },
{ id: 'c005_n17', title: '建立自信', subtitle: '相信自己的力量', duration: 6 },
{ id: 'c005_n18', title: '处理压力', subtitle: '如何应对生活压力', duration: 5 },
{ id: 'c005_n19', title: '寻找意义', subtitle: '找到生活的方向', duration: 8 },
{ id: 'c005_n20', title: '持续成长', subtitle: '成为更好的自己', duration: 7 },
],
},
],
},
{
id: 'course_006',
title: '如何建立自信',
subtitle: 'i人女孩的自信成长课',
description: '从理解自己到接纳自己,从建立自信到展现魅力。帮助内向女孩找到内在的力量,成为自信的自己。',
coverImage: 'star.fill',
chapters: [
{
id: 'c006_ch01',
title: '第一章:理解自信',
nodes: [
{ id: 'c006_n01', title: '什么是真正的自信?', subtitle: '重新定义自信', duration: 6 },
{ id: 'c006_n02', title: '自信与自负的区别', subtitle: '理解自信的本质', duration: 5 },
{ id: 'c006_n03', title: 'i人的自信优势', subtitle: '发现你的独特魅力', duration: 7 },
{ id: 'c006_n04', title: '自信的障碍', subtitle: '什么在阻止你自信', duration: 6 },
{ id: 'c006_n05', title: '从自卑到自信', subtitle: '改变的起点', duration: 8 },
],
},
{
id: 'c006_ch02',
title: '第二章:建立内在自信',
nodes: [
{ id: 'c006_n06', title: '认识你的优势', subtitle: '发现自己的闪光点', duration: 6 },
{ id: 'c006_n07', title: '接受不完美', subtitle: '允许自己犯错', duration: 5 },
{ id: 'c006_n08', title: '建立自我价值感', subtitle: '相信自己的价值', duration: 7 },
{ id: 'c006_n09', title: '停止自我批评', subtitle: '如何善待自己', duration: 6 },
{ id: 'c006_n10', title: '培养自我肯定', subtitle: '每天给自己鼓励', duration: 8 },
],
},
{
id: 'c006_ch03',
title: '第三章:展现自信',
nodes: [
{ id: 'c006_n11', title: '身体语言的力量', subtitle: '如何展现自信', duration: 6 },
{ id: 'c006_n12', title: '声音的力量', subtitle: '如何自信地说话', duration: 7 },
{ id: 'c006_n13', title: '眼神交流', subtitle: '建立连接的方式', duration: 5 },
{ id: 'c006_n14', title: '表达你的观点', subtitle: '如何自信地发言', duration: 8 },
{ id: 'c006_n15', title: '处理质疑', subtitle: '如何应对挑战', duration: 6 },
],
},
{
id: 'c006_ch04',
title: '第四章:持续成长',
nodes: [
{ id: 'c006_n16', title: '从小事开始', subtitle: '建立自信的日常', duration: 5 },
{ id: 'c006_n17', title: '走出舒适圈', subtitle: '挑战自己的边界', duration: 7 },
{ id: 'c006_n18', title: '从失败中学习', subtitle: '把挫折变成成长', duration: 6 },
{ id: 'c006_n19', title: '建立支持系统', subtitle: '找到你的后盾', duration: 8 },
{ id: 'c006_n20', title: '成为自信的自己', subtitle: '持续成长的力量', duration: 7 },
],
},
],
},
{
id: 'course_007',
title: '如何拒绝他人',
subtitle: 'i人女孩的边界建立课',
description: '从理解边界到建立边界,从学会拒绝到维护关系。帮助内向女孩建立健康的边界,保护自己的能量。',
coverImage: 'hand.raised.fill',
chapters: [
{
id: 'c007_ch01',
title: '第一章:理解边界',
nodes: [
{ id: 'c007_n01', title: '什么是边界?', subtitle: '理解边界的概念', duration: 5 },
{ id: 'c007_n02', title: '为什么边界很重要', subtitle: '边界对i人的意义', duration: 6 },
{ id: 'c007_n03', title: '边界与自私的区别', subtitle: '理解健康的边界', duration: 7 },
{ id: 'c007_n04', title: 'i人为什么难以拒绝', subtitle: '理解你的困难', duration: 6 },
{ id: 'c007_n05', title: '边界的好处', subtitle: '建立边界后的改变', duration: 8 },
],
},
{
id: 'c007_ch02',
title: '第二章:学会说"不"',
nodes: [
{ id: 'c007_n06', title: '拒绝的艺术', subtitle: '如何优雅地拒绝', duration: 6 },
{ id: 'c007_n07', title: '拒绝的句式', subtitle: '实用的拒绝技巧', duration: 5 },
{ id: 'c007_n08', title: '不需要解释', subtitle: '拒绝不需要理由', duration: 7 },
{ id: 'c007_n09', title: '处理对方的反应', subtitle: '如何应对压力', duration: 6 },
{ id: 'c007_n10', title: '坚持你的决定', subtitle: '如何不被说服', duration: 8 },
],
},
{
id: 'c007_ch03',
title: '第三章:不同场景的拒绝',
nodes: [
{ id: 'c007_n11', title: '拒绝工作请求', subtitle: '职场中的边界', duration: 6 },
{ id: 'c007_n12', title: '拒绝社交邀请', subtitle: '保护你的能量', duration: 5 },
{ id: 'c007_n13', title: '拒绝借钱', subtitle: '财务边界', duration: 7 },
{ id: 'c007_n14', title: '拒绝情感绑架', subtitle: '情感边界', duration: 6 },
{ id: 'c007_n15', title: '拒绝不合理要求', subtitle: '维护你的权利', duration: 8 },
],
},
{
id: 'c007_ch04',
title: '第四章:维护边界',
nodes: [
{ id: 'c007_n16', title: '建立边界后的关系', subtitle: '如何维护关系', duration: 6 },
{ id: 'c007_n17', title: '处理边界冲突', subtitle: '当边界被挑战时', duration: 7 },
{ id: 'c007_n18', title: '重新建立边界', subtitle: '如何修复边界', duration: 5 },
{ id: 'c007_n19', title: '边界与同理心', subtitle: '保持善良与边界', duration: 8 },
{ id: 'c007_n20', title: '成为边界的主人', subtitle: '持续维护的力量', duration: 7 },
],
},
],
},
{
id: 'course_008',
title: '如何独处',
subtitle: 'i人女孩的独处智慧课',
description: '从理解独处到享受独处,从孤独到自由。帮助内向女孩发现独处的美好,建立与自己的深度连接。',
coverImage: 'moon.stars.fill',
chapters: [
{
id: 'c008_ch01',
title: '第一章:理解独处',
nodes: [
{ id: 'c008_n01', title: '独处与孤独的区别', subtitle: '理解独处的本质', duration: 6 },
{ id: 'c008_n02', title: 'i人为什么需要独处', subtitle: '独处对i人的意义', duration: 7 },
{ id: 'c008_n03', title: '独处的心理益处', subtitle: '独处带来的好处', duration: 5 },
{ id: 'c008_n04', title: '社会对独处的误解', subtitle: '打破刻板印象', duration: 6 },
{ id: 'c008_n05', title: '独处的不同形式', subtitle: '找到你的独处方式', duration: 8 },
],
},
{
id: 'c008_ch02',
title: '第二章:享受独处',
nodes: [
{ id: 'c008_n06', title: '独处的活动清单', subtitle: '一个人可以做什么', duration: 6 },
{ id: 'c008_n07', title: '独处时的自我关怀', subtitle: '如何善待自己', duration: 7 },
{ id: 'c008_n08', title: '独处与创造力', subtitle: '激发你的灵感', duration: 5 },
{ id: 'c008_n09', title: '独处与反思', subtitle: '深入思考的时间', duration: 6 },
{ id: 'c008_n10', title: '独处与成长', subtitle: '自我提升的时光', duration: 8 },
],
},
{
id: 'c008_ch03',
title: '第三章:独处的挑战',
nodes: [
{ id: 'c008_n11', title: '处理独处的焦虑', subtitle: '如何克服不安', duration: 6 },
{ id: 'c008_n12', title: '独处与社交的平衡', subtitle: '找到你的节奏', duration: 7 },
{ id: 'c008_n13', title: '独处时的情绪管理', subtitle: '如何处理情绪', duration: 5 },
{ id: 'c008_n14', title: '独处与外界压力', subtitle: '如何应对质疑', duration: 6 },
{ id: 'c008_n15', title: '从独处到连接', subtitle: '独处后的社交', duration: 8 },
],
},
{
id: 'c008_ch04',
title: '第四章:独处的智慧',
nodes: [
{ id: 'c008_n16', title: '独处与自我认知', subtitle: '了解真实的自己', duration: 6 },
{ id: 'c008_n17', title: '独处与内心平静', subtitle: '找到内心的宁静', duration: 7 },
{ id: 'c008_n18', title: '独处与决策', subtitle: '独立思考的力量', duration: 5 },
{ id: 'c008_n19', title: '独处与目标', subtitle: '规划你的未来', duration: 8 },
{ id: 'c008_n20', title: '成为独处的自己', subtitle: '享受独处的自由', duration: 7 },
],
},
],
},
];
// ========== 创建所有课程 ==========
const createdCourses = [];
for (const courseData of coursesData) {
// 计算总节点数
const totalNodes = courseData.chapters.reduce((sum, ch) => sum + ch.nodes.length, 0);
const course = await prisma.course.upsert({
where: { id: courseData.id },
update: {
title: courseData.title,
subtitle: courseData.subtitle,
description: courseData.description,
coverImage: courseData.coverImage || null,
totalNodes,
},
create: {
id: courseData.id,
title: courseData.title,
subtitle: courseData.subtitle,
description: courseData.description,
coverImage: courseData.coverImage || null,
totalNodes,
},
});
createdCourses.push(course);
console.log(`✅ 创建课程: ${course.title} (${totalNodes} 个节点)`);
// 创建章节和节点
let globalNodeOrder = 1;
const allNodes = [];
for (let chapterIndex = 0; chapterIndex < courseData.chapters.length; chapterIndex++) {
const chapterData = courseData.chapters[chapterIndex];
const chapterOrder = chapterIndex + 1;
// 创建章节
const chapter = await prisma.courseChapter.upsert({
where: {
courseId_orderIndex: {
courseId: course.id,
orderIndex: chapterOrder,
},
},
update: {
title: chapterData.title,
},
create: {
id: chapterData.id,
courseId: course.id,
title: chapterData.title,
orderIndex: chapterOrder,
},
});
// 创建节点
for (const nodeData of chapterData.nodes) {
const node = await prisma.courseNode.upsert({
where: { id: nodeData.id },
update: {
title: nodeData.title,
subtitle: nodeData.subtitle,
duration: nodeData.duration,
orderIndex: globalNodeOrder,
chapterId: chapter.id,
},
create: {
id: nodeData.id,
courseId: course.id,
chapterId: chapter.id,
title: nodeData.title,
subtitle: nodeData.subtitle,
duration: nodeData.duration,
orderIndex: globalNodeOrder++,
},
});
allNodes.push(node);
}
}
// 为所有节点创建基础幻灯片
for (const node of allNodes) {
const slides = [
{
id: `${node.id}_slide_01`,
nodeId: node.id,
slideType: 'text',
orderIndex: 1,
content: {
title: node.title,
paragraphs: [
`欢迎学习:${node.title}`,
node.subtitle || '开始你的成长之旅',
'让我们深入探索这个主题,理解其中的智慧。',
],
},
effect: 'fade_in',
},
{
id: `${node.id}_slide_02`,
nodeId: node.id,
slideType: 'text',
orderIndex: 2,
content: {
title: '核心概念',
paragraphs: [
'每一个成长节点,都蕴含着深刻的洞察。',
'通过系统化的学习,我们可以逐步提升自己的能力。',
'关键是要保持开放的心态,持续学习和反思。',
],
},
effect: 'fade_in',
},
{
id: `${node.id}_slide_03`,
nodeId: node.id,
slideType: 'text',
orderIndex: 3,
content: {
title: '实践要点',
paragraphs: [
'1. 理解核心概念',
'2. 应用到实际场景',
'3. 持续反思和优化',
],
},
effect: 'fade_in',
},
{
id: `${node.id}_slide_04`,
nodeId: node.id,
slideType: 'text',
orderIndex: 4,
content: {
title: '本节小结',
paragraphs: [
`你已经完成了「${node.title}」的学习。`,
'记住:成长是一个持续的过程,',
'每天进步一点点,最终会带来巨大的改变。',
],
},
effect: 'fade_in',
},
];
for (const slideData of slides) {
await prisma.nodeSlide.upsert({
where: { id: slideData.id },
update: {
slideType: slideData.slideType,
orderIndex: slideData.orderIndex,
content: slideData.content as any,
effect: slideData.effect,
},
create: {
id: slideData.id,
nodeId: slideData.nodeId,
slideType: slideData.slideType,
orderIndex: slideData.orderIndex,
content: slideData.content as any,
effect: slideData.effect,
},
});
}
}
console.log(`✅ 为课程 ${course.title} 创建了 ${allNodes.length} 个节点和 ${allNodes.length * 4} 个幻灯片`);
}
// ========== 清理所有学习进度数据 ==========
console.log('\n📊 清理所有学习进度数据...');
await prisma.userLearningProgress.deleteMany({});
console.log('✅ 已清理所有学习进度数据,所有节点回到初始状态(未开始/锁定)');
console.log('\n🎉 所有课程测试数据创建完成!');
console.log('\n📝 测试账号(所有账号验证码均为: 123456');
console.log(' 测试用户1非会员未内购: 13800000001');
console.log(' 测试用户2非会员未内购: 13800000002');
console.log(' 测试用户3非会员未内购: 13800000003');
console.log(' 测试用户4非会员未内购: 13800000004');
console.log('\n📚 创建的课程:');
for (const course of createdCourses) {
console.log(` - ${course.title}: ${course.totalNodes} 个节点`);
}
console.log('\n💡 状态说明:');
console.log(' - 所有用户都是非会员isPro = false');
console.log(' - 所有学习进度已清空');
console.log(' - 每个课程的前 2 个节点可访问not_started 状态)');
console.log(' - 第 3 个节点开始锁定locked 状态,需要会员)');
}
main()
.catch((e) => {
console.error('❌ Seed 执行失败:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>按章分块测试</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container { max-width: 1600px; margin: 0 auto; }
h1 { color: white; text-align: center; margin-bottom: 20px; font-size: 28px; }
.subtitle { color: rgba(255,255,255,0.8); text-align: center; margin-bottom: 30px; font-size: 14px; }
.main-content { display: grid; grid-template-columns: 400px 1fr; gap: 20px; }
@media (max-width: 1000px) { .main-content { grid-template-columns: 1fr; } }
.panel { background: white; border-radius: 16px; padding: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
.panel-title {
font-size: 18px; font-weight: 600; color: #333; margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.panel-title::before {
content: ''; width: 4px; height: 20px;
background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 2px;
}
.btn-row { display: flex; gap: 12px; margin-top: 16px; }
button {
padding: 12px 24px; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s;
}
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; flex: 1; }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.btn-secondary { background: #f0f0f0; color: #666; }
.btn-secondary:hover { background: #e0e0e0; }
/* 文件上传区域 */
.upload-area {
border: 2px dashed #d0d0d0; border-radius: 12px; padding: 32px;
text-align: center; cursor: pointer; transition: all 0.3s;
background: #fafafa;
}
.upload-area:hover { border-color: #667eea; background: #f5f5ff; }
.upload-area.dragover { border-color: #667eea; background: #eef2ff; }
.upload-icon { font-size: 48px; margin-bottom: 12px; }
.upload-text { color: #666; font-size: 14px; }
.upload-hint { color: #999; font-size: 12px; margin-top: 8px; }
/* 文件列表 */
.file-list { margin-top: 16px; max-height: 300px; overflow-y: auto; }
.file-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; background: #f8f9fa; border-radius: 8px;
margin-bottom: 8px; font-size: 13px;
}
.file-item .icon { font-size: 20px; }
.file-item .name { flex: 1; font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-item .size { color: #888; font-size: 12px; }
.file-item .status { font-size: 12px; padding: 2px 8px; border-radius: 4px; }
.file-item .status.pending { background: #fef3c7; color: #92400e; }
.file-item .status.processing { background: #dbeafe; color: #1e40af; }
.file-item .status.success { background: #d1fae5; color: #065f46; }
.file-item .status.error { background: #fee2e2; color: #991b1b; }
.file-item .remove { cursor: pointer; color: #999; padding: 4px; }
.file-item .remove:hover { color: #e53e3e; }
/* 结果区域 */
.results-container { max-height: calc(100vh - 200px); overflow-y: auto; }
.result-card {
border: 1px solid #e0e0e0; border-radius: 12px; margin-bottom: 16px; overflow: hidden;
}
.result-header {
display: flex; align-items: center; gap: 12px; padding: 16px;
background: #f8f9fa; border-bottom: 1px solid #e0e0e0;
}
.result-header .icon { font-size: 24px; }
.result-header .info { flex: 1; }
.result-header .filename { font-weight: 600; color: #333; }
.result-header .meta { font-size: 12px; color: #888; margin-top: 2px; }
.result-header .badge {
padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
}
.result-header .badge.success { background: #d1fae5; color: #065f46; }
.result-header .badge.error { background: #fee2e2; color: #991b1b; }
.result-stats {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
padding: 12px 16px; background: white; border-bottom: 1px solid #e0e0e0;
}
.result-stats .stat { text-align: center; }
.result-stats .stat-value { font-size: 18px; font-weight: 700; color: #667eea; }
.result-stats .stat-label { font-size: 11px; color: #888; }
.result-chunks { padding: 16px; background: white; }
.chunk-item {
border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 8px;
overflow: hidden; font-size: 13px;
}
.chunk-header {
display: flex; align-items: center; gap: 8px; padding: 10px 12px;
background: #fafafa; cursor: pointer;
}
.chunk-header:hover { background: #f0f0f0; }
.chunk-order {
width: 24px; height: 24px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: white; background: #667eea;
}
.chunk-title { flex: 1; font-weight: 500; color: #333; }
.chunk-meta { font-size: 11px; color: #888; }
.chunk-content {
padding: 12px; font-size: 12px; line-height: 1.5; color: #555;
border-top: 1px solid #e8e8e8; background: white;
white-space: pre-wrap; max-height: 150px; overflow-y: auto;
}
.empty-state { text-align: center; padding: 60px 20px; color: #888; }
.empty-state .icon { font-size: 48px; margin-bottom: 16px; }
.progress-bar {
height: 4px; background: #e0e0e0; border-radius: 2px;
margin-top: 16px; overflow: hidden;
}
.progress-bar .fill {
height: 100%; background: linear-gradient(135deg, #667eea, #764ba2);
transition: width 0.3s;
}
/* 小按钮 */
.btn-small {
padding: 6px 12px; font-size: 12px; border: 1px solid #d0d0d0;
background: white; border-radius: 6px; cursor: pointer;
transition: all 0.2s;
}
.btn-small:hover { background: #f5f5f5; border-color: #667eea; }
</style>
</head>
<body>
<div class="container">
<h1>按章分块测试(批量)</h1>
<p class="subtitle">支持批量上传文档,识别章级结构并分块</p>
<div class="main-content">
<!-- 左侧:上传区 -->
<div class="panel">
<div class="panel-title">上传文档</div>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📚</div>
<div class="upload-text">点击上传或拖拽文件</div>
<div class="upload-hint">支持 Word、PDF、EPUB可多选</div>
<div class="upload-hint">单文件最大 100MB</div>
</div>
<input type="file" id="fileInput" accept=".pdf,.docx,.epub" multiple style="display: none;" onchange="handleFileSelect(event)">
<div class="file-list" id="fileList"></div>
<div class="progress-bar" id="progressBar" style="display: none;">
<div class="fill" id="progressFill" style="width: 0%;"></div>
</div>
<div class="btn-row">
<button class="btn-primary" id="testBtn" onclick="startBatchProcess()">开始分块</button>
<button class="btn-secondary" onclick="clearAll()">清空</button>
</div>
</div>
<!-- 右侧:结果区 -->
<div class="panel" style="flex: 1;">
<div class="panel-title">分块结果</div>
<div class="results-container" id="resultsContainer">
<div class="empty-state" id="emptyState">
<div class="icon">📄</div>
<div>上传文档后点击"开始分块"</div>
</div>
</div>
</div>
</div>
</div>
<script>
let files = [];
let results = [];
// 文件拖拽处理
const uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
addFiles(Array.from(e.dataTransfer.files));
});
function handleFileSelect(event) {
addFiles(Array.from(event.target.files));
event.target.value = '';
}
function addFiles(newFiles) {
const validExts = ['.pdf', '.docx', '.epub'];
newFiles.forEach(file => {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
if (!validExts.includes(ext)) {
alert(`不支持的格式: ${file.name}`);
return;
}
if (file.size > 100 * 1024 * 1024) {
alert(`文件过大: ${file.name}`);
return;
}
// 避免重复
if (!files.find(f => f.name === file.name && f.size === file.size)) {
files.push(file);
}
});
renderFileList();
}
function removeFile(index) {
files.splice(index, 1);
renderFileList();
}
function renderFileList() {
const container = document.getElementById('fileList');
if (files.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = files.map((file, i) => `
<div class="file-item" id="file-${i}">
<span class="icon">${getFileIcon(file.name)}</span>
<span class="name" title="${file.name}">${file.name}</span>
<span class="size">${formatFileSize(file.size)}</span>
<span class="status pending" id="status-${i}">待处理</span>
<span class="remove" onclick="removeFile(${i})"></span>
</div>
`).join('');
}
function getFileIcon(filename) {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
if (ext === '.pdf') return '📕';
if (ext === '.docx') return '📘';
if (ext === '.epub') return '📗';
return '📄';
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function clearAll() {
files = [];
results = [];
renderFileList();
document.getElementById('resultsContainer').innerHTML = `
<div class="empty-state" id="emptyState">
<div class="icon">📄</div>
<div>上传文档后点击"开始分块"</div>
</div>
`;
document.getElementById('progressBar').style.display = 'none';
}
async function startBatchProcess() {
if (files.length === 0) {
alert('请先上传文件');
return;
}
const btn = document.getElementById('testBtn');
btn.disabled = true;
btn.textContent = '处理中...';
document.getElementById('emptyState')?.remove();
document.getElementById('progressBar').style.display = 'block';
results = [];
const total = files.length;
for (let i = 0; i < files.length; i++) {
const file = files[i];
updateFileStatus(i, 'processing', '处理中...');
updateProgress((i / total) * 100);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/playground/chunking/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.success) {
updateFileStatus(i, 'success', `${result.data.totalChunks} 块`);
results.push({ file, success: true, data: result.data });
} else {
throw new Error(result.error);
}
} catch (error) {
updateFileStatus(i, 'error', '失败');
results.push({ file, success: false, error: error.message });
}
renderResults();
}
updateProgress(100);
btn.disabled = false;
btn.textContent = '开始分块';
}
function updateFileStatus(index, status, text) {
const el = document.getElementById(`status-${index}`);
if (el) {
el.className = `status ${status}`;
el.textContent = text;
}
}
function updateProgress(percent) {
document.getElementById('progressFill').style.width = `${percent}%`;
}
function renderResults() {
const container = document.getElementById('resultsContainer');
container.innerHTML = results.map((r, i) => {
if (!r.success) {
return `
<div class="result-card">
<div class="result-header">
<span class="icon">${getFileIcon(r.file.name)}</span>
<div class="info">
<div class="filename">${escapeHtml(r.file.name)}</div>
<div class="meta">${formatFileSize(r.file.size)}</div>
</div>
<span class="badge error">失败</span>
</div>
<div style="padding: 16px; color: #991b1b; font-size: 13px;">
错误: ${escapeHtml(r.error)}
</div>
</div>
`;
}
const d = r.data;
return `
<div class="result-card" data-index="${i}">
<div class="result-header">
<span class="icon">${getFileIcon(r.file.name)}</span>
<div class="info">
<div class="filename">${escapeHtml(r.file.name)}</div>
<div class="meta">${formatFileSize(r.file.size)} · ${d.pattern || '无结构'}${d.failureReason ? ' · ⚠️ ' + d.failureReason : ''}</div>
</div>
<span class="badge ${d.success ? 'success' : 'error'}">${d.success ? d.totalChunks + ' 块' : '失败'}</span>
</div>
<div class="result-stats">
<div class="stat">
<div class="stat-value">${d.totalChunks}</div>
<div class="stat-label">分块数</div>
</div>
<div class="stat">
<div class="stat-value">${(d.totalCharacters / 1000).toFixed(1)}k</div>
<div class="stat-label">字符数</div>
</div>
<div class="stat">
<div class="stat-value">${d.duration}</div>
<div class="stat-label">耗时</div>
</div>
<div class="stat">
<div class="stat-value">${d.chunks.length > 0 ? Math.round(d.totalCharacters / d.chunks.length) : 0}</div>
<div class="stat-label">平均/块</div>
</div>
</div>
${d.chunks.length > 0 ? `
<div class="result-chunks">
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
<button class="btn-small" onclick="toggleAllChunks(${i}, true)">📖 展开全部</button>
<button class="btn-small" onclick="toggleAllChunks(${i}, false)">📕 收起全部</button>
<button class="btn-small" onclick="openChunksInNewWindow(${i})">🔗 新窗口查看</button>
</div>
<div id="chunks-container-${i}">
${d.chunks.map((chunk, j) => `
<div class="chunk-item">
<div class="chunk-header" onclick="toggleContent(this)">
<div class="chunk-order">${j + 1}</div>
<div class="chunk-title">${escapeHtml(chunk.title)}</div>
<div class="chunk-meta">${chunk.contentLength} 字</div>
</div>
${chunk.content ? `<div class="chunk-content" style="display: none;">${escapeHtml(chunk.content)}</div>` : ''}
</div>
`).join('')}
</div>
</div>
` : `
<div style="padding: 16px; text-align: center; color: #888; font-size: 13px;">
未识别到章节结构
</div>
`}
</div>
`;
}).join('');
}
function toggleContent(header) {
const content = header.parentElement.querySelector('.chunk-content');
if (content) {
content.style.display = content.style.display === 'none' ? 'block' : 'none';
}
}
function toggleAllChunks(resultIndex, expand) {
const container = document.getElementById(`chunks-container-${resultIndex}`);
if (!container) return;
const contents = container.querySelectorAll('.chunk-content');
contents.forEach(el => {
el.style.display = expand ? 'block' : 'none';
});
}
function openChunksInNewWindow(resultIndex) {
const r = results[resultIndex];
if (!r || !r.data || !r.data.chunks) return;
const d = r.data;
// 构建 HTML
let chunksHtml = '';
d.chunks.forEach((chunk, j) => {
chunksHtml += '<div class="chunk">' +
'<div class="chunk-header">' +
'<div class="chunk-order">' + (j + 1) + '</div>' +
'<div class="chunk-title">' + escapeHtml(chunk.title) + '</div>' +
'<div class="chunk-length">' + chunk.contentLength + ' 字</div>' +
'</div>' +
'<div class="chunk-content">' + escapeHtml(chunk.content) + '</div>' +
'</div>';
});
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">' +
'<title>' + escapeHtml(r.file.name) + ' - 分块结果</title>' +
'<style>' +
'body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 20px; max-width: 1000px; margin: 0 auto; }' +
'h1 { font-size: 20px; margin-bottom: 20px; }' +
'.meta { color: #666; font-size: 14px; margin-bottom: 20px; }' +
'.chunk { border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 16px; overflow: hidden; }' +
'.chunk-header { background: #f5f5f5; padding: 12px 16px; font-weight: 600; display: flex; align-items: center; gap: 12px; }' +
'.chunk-order { width: 28px; height: 28px; background: #667eea; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; }' +
'.chunk-title { flex: 1; }' +
'.chunk-length { color: #888; font-size: 12px; }' +
'.chunk-content { padding: 16px; line-height: 1.8; white-space: pre-wrap; font-size: 14px; }' +
'</style></head><body>' +
'<h1>' + escapeHtml(r.file.name) + '</h1>' +
'<div class="meta">共 ' + d.chunks.length + ' 个分块 · ' + d.totalCharacters.toLocaleString() + ' 字符 · 模式: ' + (d.pattern || '未知') + '</div>' +
chunksHtml +
'</body></html>';
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>课程内容批量录入工具 - Wild Growth</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.content {
padding: 30px;
}
.form-group {
margin-bottom: 25px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
font-size: 14px;
}
input, textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
min-height: 200px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 20px;
}
button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.result {
margin-top: 25px;
padding: 20px;
border-radius: 8px;
display: none;
}
.result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.result.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.result pre {
margin-top: 10px;
padding: 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
}
.example {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 25px;
border-left: 4px solid #667eea;
}
.example h3 {
margin-bottom: 12px;
color: #667eea;
font-size: 16px;
}
.example code {
display: block;
background: white;
padding: 12px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 12px;
overflow-x: auto;
white-space: pre;
}
.info-box {
background: #e7f3ff;
border: 1px solid #b3d9ff;
border-radius: 8px;
padding: 15px;
margin-bottom: 25px;
color: #004085;
}
.info-box strong {
display: block;
margin-bottom: 8px;
}
.info-box ul {
margin-left: 20px;
margin-top: 8px;
}
.info-box li {
margin-bottom: 4px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📚 课程内容批量录入工具</h1>
<p>Wild Growth - 批量创建章节和节点</p>
</div>
<div class="content">
<div class="info-box">
<strong>📋 使用说明:</strong>
<ul>
<li>1. 填写课程 IDcourse_001</li>
<li>2. 填写 JWT Token从登录接口获取</li>
<li>3. 在 JSON 编辑器中输入章节和节点数据</li>
<li>4. 点击"提交创建"按钮</li>
<li>5. 查看创建结果</li>
</ul>
</div>
<div class="form-group">
<label for="apiUrl">API 地址</label>
<input type="text" id="apiUrl" value="http://localhost:3000" placeholder="http://localhost:3000">
</div>
<div class="form-group">
<label for="courseId">课程 ID *</label>
<input type="text" id="courseId" value="course_001" placeholder="course_001" required>
</div>
<div class="form-group">
<label for="token">JWT Token *</label>
<input type="text" id="token" placeholder="从登录接口获取的 token" required>
</div>
<div class="example">
<h3>📝 JSON 数据格式示例:</h3>
<code>{
"chapters": [
{
"title": "第一章:重新认识大脑",
"order": 1,
"nodes": [
{
"id": "node_01_01",
"title": "我们为什么会痛苦?",
"subtitle": "理解痛苦的根源",
"duration": 5
},
{
"id": "node_01_02",
"title": "大脑的节能机制",
"subtitle": "认识大脑的工作原理",
"duration": 6
}
]
},
{
"title": "第二章:潜意识的智慧",
"order": 2,
"nodes": [
{
"id": "node_02_01",
"title": "潜意识的运作",
"subtitle": "探索潜意识",
"duration": 5
}
]
}
]
}</code>
</div>
<div class="form-group">
<label for="jsonData">章节和节点数据 (JSON) *</label>
<textarea id="jsonData" placeholder='请输入 JSON 数据...'></textarea>
</div>
<div class="button-group">
<button class="btn-secondary" onclick="loadExample()">加载示例数据</button>
<button class="btn-secondary" onclick="formatJSON()">格式化 JSON</button>
<button class="btn-primary" onclick="submitData()">提交创建</button>
</div>
<div id="result" class="result"></div>
</div>
</div>
<script>
function loadExample() {
const example = {
chapters: [
{
title: "第一章:重新认识大脑",
order: 1,
nodes: [
{
id: "node_01_01",
title: "我们为什么会痛苦?",
subtitle: "理解痛苦的根源",
duration: 5
},
{
id: "node_01_02",
title: "大脑的节能机制",
subtitle: "认识大脑的工作原理",
duration: 6
},
{
id: "node_01_03",
title: "认知偏差的陷阱",
subtitle: "识别认知陷阱",
duration: 7
}
]
},
{
title: "第二章:潜意识的智慧",
order: 2,
nodes: [
{
id: "node_02_01",
title: "潜意识的运作",
subtitle: "探索潜意识",
duration: 5
},
{
id: "node_02_02",
title: "直觉与理性",
subtitle: "平衡直觉与理性",
duration: 6
}
]
}
]
};
document.getElementById('jsonData').value = JSON.stringify(example, null, 2);
}
function formatJSON() {
const textarea = document.getElementById('jsonData');
try {
const data = JSON.parse(textarea.value);
textarea.value = JSON.stringify(data, null, 2);
showResult('success', 'JSON 格式化成功!');
} catch (e) {
showResult('error', 'JSON 格式错误:' + e.message);
}
}
async function submitData() {
const apiUrl = document.getElementById('apiUrl').value.trim();
const courseId = document.getElementById('courseId').value.trim();
const token = document.getElementById('token').value.trim();
const jsonData = document.getElementById('jsonData').value.trim();
if (!courseId || !token || !jsonData) {
showResult('error', '请填写所有必填字段!');
return;
}
let data;
try {
data = JSON.parse(jsonData);
} catch (e) {
showResult('error', 'JSON 格式错误:' + e.message);
return;
}
// 验证数据结构
if (!data.chapters || !Array.isArray(data.chapters)) {
showResult('error', 'JSON 数据格式错误:必须包含 chapters 数组');
return;
}
const url = `${apiUrl}/api/courses/${courseId}/chapters-nodes`;
try {
showResult('success', '正在提交...', true);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
showResult('success', `✅ 创建成功!\n\n章节数${result.data.chapters_created}\n节点数${result.data.nodes_created}\n总节点数${result.data.total_nodes}`, false, result);
} else {
showResult('error', `❌ 创建失败:${result.message || '未知错误'}`, false, result);
}
} catch (error) {
showResult('error', `❌ 请求失败:${error.message}`);
}
}
function showResult(type, message, loading = false, data = null) {
const resultDiv = document.getElementById('result');
resultDiv.className = `result ${type}`;
resultDiv.style.display = 'block';
let content = `<strong>${message}</strong>`;
if (data) {
content += `<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
resultDiv.innerHTML = content;
if (!loading) {
resultDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// 页面加载时自动加载示例数据
window.onload = function() {
loadExample();
};
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

197
backend/public/index.html Normal file
View File

@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电子成长 - AI 驱动的知识学习应用</title>
<meta name="description" content="电子成长是一款 AI 驱动的知识学习应用。输入任何你想学的主题AI 自动生成交互式学习卡片,让学习变得轻松有趣。">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
.header {
text-align: center;
margin-bottom: 60px;
padding: 40px 0;
}
.header h1 {
font-size: 48px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 16px;
letter-spacing: -1px;
}
.header .subtitle {
font-size: 24px;
color: #666;
font-weight: 300;
margin-bottom: 8px;
}
.header .tagline {
font-size: 18px;
color: #999;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
margin-bottom: 60px;
}
.feature-card {
background: white;
padding: 30px;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}
.feature-icon {
font-size: 48px;
margin-bottom: 20px;
}
.feature-card h3 {
font-size: 22px;
color: #1a1a1a;
margin-bottom: 12px;
}
.feature-card p {
color: #666;
font-size: 16px;
line-height: 1.6;
}
.cta-section {
text-align: center;
background: white;
padding: 60px 40px;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 60px;
}
.cta-section h2 {
font-size: 36px;
color: #1a1a1a;
margin-bottom: 20px;
}
.cta-section p {
font-size: 18px;
color: #666;
margin-bottom: 30px;
}
.app-store-badge {
display: inline-block;
margin: 10px;
transition: transform 0.3s ease;
}
.app-store-badge:hover {
transform: scale(1.05);
}
.app-store-badge img {
height: 60px;
width: auto;
}
.footer {
text-align: center;
padding: 40px 0;
color: #999;
font-size: 14px;
}
.footer a {
color: #2266ff;
text-decoration: none;
margin: 0 15px;
}
.footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.header h1 {
font-size: 36px;
}
.header .subtitle {
font-size: 20px;
}
.features {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>电子成长</h1>
<p class="subtitle">无痛学习的魔法</p>
<p class="tagline">AI 驱动的知识学习应用</p>
</div>
<div class="features">
<div class="feature-card">
<div class="feature-icon">🤖</div>
<h3>AI 智能生成</h3>
<p>输入任何你想学的主题AI 自动生成结构化的交互式学习卡片,从入门到进阶一键搞定。</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎯</div>
<h3>交互式卡片</h3>
<p>精心设计的沉浸式学习体验,通过交互式卡片让复杂知识变得轻松易懂。</p>
</div>
<div class="feature-card">
<div class="feature-icon">👩‍🏫</div>
<h3>多种讲解风格</h3>
<p>从轻松活泼到系统严谨,多位 AI 讲师供你选择,找到最适合自己的学习方式。</p>
</div>
<div class="feature-card">
<div class="feature-icon">📚</div>
<h3>精选课程库</h3>
<p>涵盖求职面试、自我提升、读书解读等热门话题,每日更新精选内容。</p>
</div>
<div class="feature-card">
<div class="feature-icon">📝</div>
<h3>学习笔记</h3>
<p>学习过程中随手记录灵感和要点,构建属于你自己的知识体系。</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h3>隐私保护</h3>
<p>自建数据分析系统,不使用任何第三方追踪 SDK您的数据安全是我们的底线。</p>
</div>
</div>
<div class="cta-section">
<h2>开始你的成长之旅</h2>
<p>免费下载,无需注册即可体验</p>
<div class="app-store-badge">
<a href="https://apps.apple.com/app/电子成长" target="_blank">
<img src="https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/zh-cn?size=250x83&releaseDate=1704067200" alt="在 App Store 下载">
</a>
</div>
</div>
<div class="footer">
<p>
<a href="/support.html">技术支持</a>
<a href="/privacy-policy.html">隐私政策</a>
<a href="/user-agreement.html">用户协议</a>
</p>
<p style="margin-top: 20px;">© 2026 电子成长. 保留所有权利.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,464 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运营位管理 - Wild Growth</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px 28px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 22px; font-weight: 600; }
.header a {
color: rgba(255,255,255,0.95);
text-decoration: none;
font-size: 14px;
}
.header a:hover { text-decoration: underline; }
.content { padding: 24px 28px; }
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.toolbar input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
width: 200px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
background: #667eea;
color: white;
}
.btn:hover { background: #5568d3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.banner-card {
border: 1px solid #e0e0e0;
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
.banner-card.deleted { opacity: 0.7; background: #f8f9fa; }
.banner-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
}
.banner-head-left { display: flex; align-items: center; gap: 12px; }
.banner-title { font-weight: 600; font-size: 16px; }
.banner-meta { font-size: 12px; color: #666; }
.banner-actions { display: flex; gap: 8px; align-items: center; }
.banner-body { padding: 14px 18px; }
.banner-courses {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.course-chip {
width: 80px;
text-align: center;
position: relative;
}
.course-chip img {
width: 80px;
height: 106px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.course-chip .title {
font-size: 11px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-chip .chip-actions {
position: absolute;
top: -6px;
right: -6px;
display: flex;
gap: 2px;
}
.course-chip .remove, .course-chip .order-btn {
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
color: white;
}
.course-chip .remove { background: #dc3545; }
.course-chip .remove:hover { background: #c82333; }
.course-chip .order-btn { background: #667eea; font-size: 10px; }
.course-chip .order-btn:hover { background: #5568d3; }
.course-chip .order-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.add-course-btn {
width: 80px;
height: 106px;
border: 2px dashed #ccc;
border-radius: 8px;
background: #fafafa;
color: #666;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.add-course-btn:hover { border-color: #667eea; color: #667eea; }
.empty-state { text-align: center; padding: 40px 20px; color: #666; }
.message { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; display: none; }
.message.success { background: #d4edda; color: #155724; }
.message.error { background: #f8d7da; color: #721c24; }
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-mask.show { display: flex; }
.modal {
background: white;
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal h3 { margin-bottom: 16px; font-size: 18px; }
.modal label { display: block; margin-bottom: 6px; font-size: 13px; color: #555; }
.modal input, .modal select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 14px;
font-size: 14px;
}
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.course-option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.course-option input[type=checkbox] { flex-shrink: 0; }
.course-option img { width: 40px; height: 53px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
.course-option span { font-size: 14px; }
.loading { text-align: center; padding: 40px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>运营位管理</h1>
<a href="course-admin.html">← 返回课程管理</a>
</div>
<div class="content">
<div id="message" class="message"></div>
<div class="toolbar">
<input type="text" id="newTitle" placeholder="新运营位标题" />
<input type="number" id="newOrder" placeholder="排序" value="1" min="1" style="width:80px" />
<button class="btn" id="btnCreate">新建运营位</button>
</div>
<div id="list"></div>
</div>
</div>
<div id="modalEdit" class="modal-mask">
<div class="modal">
<h3>编辑运营位</h3>
<input type="hidden" id="editId" />
<label>标题</label>
<input type="text" id="editTitle" />
<label>排序</label>
<input type="number" id="editOrder" min="1" />
<label><input type="checkbox" id="editEnabled" checked /> 启用</label>
<div class="modal-actions">
<button class="btn btn-secondary" id="btnEditCancel">取消</button>
<button class="btn" id="btnEditSave">保存</button>
</div>
</div>
</div>
<div id="modalAddCourse" class="modal-mask">
<div class="modal">
<h3>添加课程到运营位(可多选)</h3>
<p id="addCourseBannerTitle" style="font-size:13px;color:#666;margin-bottom:12px;"></p>
<div id="courseList"></div>
<div class="modal-actions" style="margin-top:16px">
<button class="btn btn-secondary" id="btnAddCourseCancel">取消</button>
<button class="btn" id="btnAddCourseSubmit">添加选中</button>
</div>
</div>
</div>
<script>
const API_BASE = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? 'http://localhost:3000' : window.location.origin;
const ADMIN = API_BASE + '/api/admin/operational-banners';
function showMsg(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = 'message ' + (type || 'success');
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
async function api(method, url, body) {
const opt = { method, headers: {} };
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
const res = await fetch(url, opt);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error?.message || data.message || res.statusText);
return data;
}
function getImageUrl(url) {
if (!url) return '';
if (url.startsWith('http')) return url;
return url.startsWith('/') ? API_BASE + url : API_BASE + '/' + url;
}
let banners = [];
let publicCourses = [];
async function loadBanners() {
const listEl = document.getElementById('list');
listEl.innerHTML = '<div class="loading">加载中...</div>';
try {
const data = await api('GET', ADMIN);
banners = data.data.banners || [];
renderBanners();
} catch (e) {
listEl.innerHTML = '<div class="message error">加载失败:' + e.message + '</div>';
}
}
function renderBanners() {
const listEl = document.getElementById('list');
if (!banners.length) {
listEl.innerHTML = '<div class="empty-state">暂无运营位,请点击「新建运营位」添加。</div>';
return;
}
listEl.innerHTML = banners.map(b => {
const deleted = !!b.deletedAt;
const courses = b.courses || [];
const coursesHtml = courses.map((c, i) => `
<div class="course-chip" data-banner-id="${b.id}" data-course-id="${c.courseId}" data-index="${i}">
<div class="chip-actions">
<button type="button" class="order-btn" title="上移" onclick="moveCourse('${b.id}',${i},-1)" ${i === 0 ? 'disabled' : ''}>&uarr;</button>
<button type="button" class="order-btn" title="下移" onclick="moveCourse('${b.id}',${i},1)" ${i === courses.length - 1 ? 'disabled' : ''}>&darr;</button>
<button type="button" class="remove" title="移除" onclick="removeCourse('${b.id}','${c.courseId}')">&times;</button>
</div>
<img src="${getImageUrl(c.cover_image) || ''}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2280%22 height=%22106%22/>'" />
<div class="title">${escapeHtml(c.title || '')}</div>
</div>
`).join('');
const addBtn = !deleted && (b.courses || []).length < 10
? `<div class="add-course-btn" onclick="openAddCourse('${b.id}', this)">+ 添加课程</div>`
: '';
return `
<div class="banner-card ${deleted ? 'deleted' : ''}" data-id="${b.id}">
<div class="banner-head">
<div class="banner-head-left">
<span class="banner-title">${escapeHtml(b.title)}</span>
<span class="banner-meta">排序 ${b.orderIndex} · ${b.isEnabled ? '启用' : '禁用'}${deleted ? ' · 已删除' : ''} · ${(b.courses || []).length}/10 门课</span>
</div>
<div class="banner-actions">
${deleted ? '' : `<button class="btn btn-sm" onclick="openEdit('${b.id}')">编辑</button><button class="btn btn-sm btn-danger" onclick="deleteBanner('${b.id}')">删除</button>`}
</div>
</div>
<div class="banner-body">
<div class="banner-courses">${coursesHtml}${addBtn}</div>
</div>
</div>
`;
}).join('');
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s ?? '';
return div.innerHTML;
}
document.getElementById('btnCreate').onclick = async () => {
const title = document.getElementById('newTitle').value.trim();
if (!title) { showMsg('请输入标题', 'error'); return; }
const orderIndex = parseInt(document.getElementById('newOrder').value, 10) || 1;
try {
await api('POST', ADMIN, { title, orderIndex, isEnabled: true });
showMsg('已创建');
document.getElementById('newTitle').value = '';
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
};
function openEdit(id) {
const b = banners.find(x => x.id === id);
if (!b) return;
document.getElementById('editId').value = b.id;
document.getElementById('editTitle').value = b.title;
document.getElementById('editOrder').value = b.orderIndex;
document.getElementById('editEnabled').checked = b.isEnabled;
document.getElementById('modalEdit').classList.add('show');
}
document.getElementById('btnEditCancel').onclick = () => document.getElementById('modalEdit').classList.remove('show');
document.getElementById('btnEditSave').onclick = async () => {
const id = document.getElementById('editId').value;
const title = document.getElementById('editTitle').value.trim();
if (!title) { showMsg('标题不能为空', 'error'); return; }
const orderIndex = parseInt(document.getElementById('editOrder').value, 10) || 1;
const isEnabled = document.getElementById('editEnabled').checked;
try {
await api('PATCH', ADMIN + '/' + id, { title, orderIndex, isEnabled });
showMsg('已保存');
document.getElementById('modalEdit').classList.remove('show');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
};
async function deleteBanner(id) {
if (!confirm('确定删除该运营位?删除后不会在发现页展示,可保留关联课程。')) return;
try {
await api('DELETE', ADMIN + '/' + id);
showMsg('已删除');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
}
async function openAddCourse(bannerId, _btnEl) {
const banner = banners.find(b => b.id === bannerId);
document.getElementById('addCourseBannerTitle').textContent = '运营位:' + (banner ? banner.title : bannerId);
const listEl = document.getElementById('courseList');
listEl.innerHTML = '<div class="loading">加载课程列表...</div>';
document.getElementById('modalAddCourse').classList.add('show');
try {
const res = await fetch(API_BASE + '/api/courses');
const data = await res.json();
publicCourses = (data.data && data.data.courses) || [];
const banner = banners.find(x => x.id === bannerId);
const inBanner = new Set((banner.courses || []).map(c => c.courseId));
const available = publicCourses.filter(c => !inBanner.has(c.id));
if (!available.length) {
listEl.innerHTML = '<p style="color:#666">没有可添加的公开课程,或已全部添加。</p>';
document.getElementById('btnAddCourseSubmit').style.display = 'none';
return;
}
window._addCourseBannerId = bannerId;
listEl.innerHTML = available.map(c => `
<label class="course-option" style="cursor:pointer;display:flex;align-items:center;gap:10px;">
<input type="checkbox" class="add-course-cb" data-course-id="${c.id}" />
<img src="${getImageUrl(c.cover_image)}" alt="" onerror="this.style.display='none'" />
<span>${escapeHtml(c.title)}</span>
</label>
`).join('');
document.getElementById('btnAddCourseSubmit').style.display = 'inline-block';
} catch (e) {
listEl.innerHTML = '<p class="message error">加载失败:' + escapeHtml(e.message) + '</p>';
}
}
document.getElementById('btnAddCourseCancel').onclick = () => document.getElementById('modalAddCourse').classList.remove('show');
document.getElementById('btnAddCourseSubmit').onclick = async () => {
const bannerId = window._addCourseBannerId;
if (!bannerId) return;
const checked = document.querySelectorAll('.add-course-cb:checked');
if (!checked.length) { showMsg('请至少勾选一门课程', 'error'); return; }
const banner = banners.find(b => b.id === bannerId);
const startOrder = (banner && banner.courses && banner.courses.length) || 0;
const courses = Array.from(checked).map((el, i) => ({
courseId: el.getAttribute('data-course-id'),
orderIndex: startOrder + i + 1
}));
try {
await api('POST', ADMIN + '/' + bannerId + '/courses/batch', { courses });
showMsg('已添加 ' + courses.length + ' 门课程');
document.getElementById('modalAddCourse').classList.remove('show');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
};
async function moveCourse(bannerId, index, delta) {
const banner = banners.find(b => b.id === bannerId);
if (!banner || !banner.courses || banner.courses.length < 2) return;
const courses = banner.courses.slice();
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= courses.length) return;
[courses[index], courses[newIndex]] = [courses[newIndex], courses[index]];
const orderPayload = courses.map((c, i) => ({ courseId: c.courseId, orderIndex: i + 1 }));
try {
await api('PUT', ADMIN + '/' + bannerId + '/courses/order', orderPayload);
showMsg('已调整顺序');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
}
async function removeCourse(bannerId, courseId) {
if (!confirm('确定从该运营位移除这门课程?')) return;
try {
await api('DELETE', ADMIN + '/' + bannerId + '/courses/' + courseId);
showMsg('已移除');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
}
loadBanners();
</script>
</body>
</html>

View File

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>隐私政策 - 电子成长</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
font-size: 28px;
}
.last-updated {
color: #7f8c8d;
font-size: 14px;
margin-bottom: 30px;
}
h2 {
color: #34495e;
margin-top: 30px;
margin-bottom: 15px;
font-size: 20px;
border-bottom: 2px solid #3498db;
padding-bottom: 5px;
}
h3 {
color: #34495e;
margin-top: 20px;
margin-bottom: 10px;
font-size: 16px;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.contact {
background: #ecf0f1;
padding: 20px;
border-radius: 5px;
margin-top: 30px;
}
.contact h3 {
color: #2c3e50;
margin-bottom: 10px;
}
.contact p {
margin-bottom: 5px;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>隐私政策</h1>
<p class="last-updated">最后更新时间2026年2月8日</p>
<h2>1. 引言</h2>
<p>
「电子成长」(以下简称"我们"或"本应用")非常重视您的隐私。本隐私政策说明了我们如何收集、使用、存储和保护您的个人信息。使用本应用即表示您同意本隐私政策的条款。
</p>
<h2>2. 我们收集的信息</h2>
<h3>2.1 您主动提供的信息</h3>
<ul>
<li><strong>账户信息</strong>:当您注册或登录时,我们收集您的手机号码(用于验证码登录)或由 Apple 提供的用户标识符(如您使用 Sign in with Apple</li>
<li><strong>个人资料</strong>:您可选择设置的昵称、头像等信息</li>
<li><strong>学习数据</strong>:您的课程学习进度、小节完成情况、学习时长等</li>
<li><strong>笔记内容</strong>:您在课程学习过程中创建的笔记及笔记本</li>
<li><strong>AI 创建课程输入</strong>您使用「AI 创建课程」功能时输入的主题或粘贴的文本,仅用于生成课程及改进服务</li>
</ul>
<h3>2.2 自动收集的信息</h3>
<p>
为改进产品体验和排查技术问题,我们通过自建的轻量级埋点系统自动收集以下信息:
</p>
<ul>
<li><strong>设备信息</strong>:设备型号(如 iPhone15,2、iOS 系统版本、App 版本号</li>
<li><strong>设备标识符</strong>:基于 identifierForVendor 生成的设备 ID持久化存储于本地用于区分匿名用户不关联广告标识符IDFA</li>
<li><strong>会话信息</strong>:每次打开 App 生成的会话 ID用于分析单次使用行为</li>
<li><strong>行为事件</strong>:页面浏览(如发现页、课程地图页)、功能使用(如 AI 创建课程、加入课程、创建笔记)、登录行为等,不包含您输入的具体文本内容</li>
<li><strong>网络类型</strong>:当前网络连接类型(如 Wi-Fi / 蜂窝网络)</li>
</ul>
<p>
上述数据通过 HTTPS 加密传输至我们自有的服务器,<strong>不使用任何第三方数据分析 SDK</strong>(如 Firebase、友盟等不与任何第三方共享。
</p>
<h2>3. 我们如何使用您的信息</h2>
<ul>
<li>提供、维护和改进核心服务发现、课程学习、AI 创建课程、笔记等)</li>
<li>同步您的学习进度、笔记和偏好设置</li>
<li>使用您输入的主题或文本进行 AI 课程生成</li>
<li>分析功能使用情况以改进产品体验(基于自建埋点数据)</li>
<li>检测、预防和解决技术问题</li>
<li>发送必要的服务通知(如安全警告)</li>
</ul>
<h2>4. 信息存储和安全</h2>
<h3>4.1 数据存储</h3>
<p>
您的数据存储在我们自有的安全服务器上(位于中国大陆),我们采用以下安全措施保护您的信息:
</p>
<ul>
<li>全链路 HTTPS 加密传输</li>
<li>数据库访问权限控制</li>
<li>敏感信息不明文存储</li>
</ul>
<h3>4.2 数据保留</h3>
<p>
我们在您使用服务期间保留您的信息。当您注销账号后,我们将在合理时间内删除您的个人信息,法律要求保留的信息除外。埋点数据为匿名或半匿名数据,不直接关联您的真实身份。
</p>
<h3>4.3 离线缓存</h3>
<p>
当网络不可用时,埋点数据会临时缓存在您的设备本地(最多 500 条),待网络恢复后自动上传并清除本地缓存。
</p>
<h2>5. 信息共享和披露</h2>
<p>我们<strong>不会向第三方出售、交易或出租</strong>您的个人信息。我们仅在以下情况下共享您的信息:</p>
<ul>
<li><strong>服务提供商</strong>:与帮助我们运营服务的可信第三方(如云服务器提供商)共享必要的基础设施信息</li>
<li><strong>法律要求</strong>:当法律、法规或政府机关依法要求时</li>
<li><strong>保护权利</strong>:为保护我们的合法权利,或保护用户及公众的安全</li>
</ul>
<h2>6. 第三方服务</h2>
<h3>6.1 Apple 服务</h3>
<p>
如果您使用 Sign in with Apple 登录,您的信息将按照 Apple 的隐私政策处理。我们仅接收 Apple 提供的用户标识符用于账户关联,不获取您的 Apple ID 密码或支付信息。
</p>
<h3>6.2 AI 服务</h3>
<p>
AI 课程生成功能使用第三方大语言模型 API。我们仅将您输入的主题或文本发送至 AI 服务提供商用于生成课程内容,不发送您的个人身份信息。具体的数据处理方式请参阅相关 AI 服务提供商的隐私政策。
</p>
<h2>7. 您的权利</h2>
<p>您对自己的个人信息享有以下权利:</p>
<ul>
<li><strong>访问权</strong>:您可以在个人中心查看您的个人资料和学习数据</li>
<li><strong>更正权</strong>:您可以随时修改您的昵称、头像等个人信息</li>
<li><strong>删除权</strong>:您可以通过应用内的"注销账号"功能删除您的账户和关联数据</li>
<li><strong>撤回同意</strong>:您可以随时停止使用本应用以撤回对数据处理的同意</li>
</ul>
<p>如需行使上述权利或有其他隐私相关需求,请通过下方联系方式与我们联系。</p>
<h2>8. 儿童隐私</h2>
<p>
本应用面向 13 岁及以上的用户。我们不会故意收集 13 岁以下儿童的个人信息。如果我们发现收集了此类信息,将立即删除。如果您是家长或监护人,发现您的孩子向我们提供了个人信息,请联系我们。
</p>
<h2>9. 隐私政策的变更</h2>
<p>
我们可能会不时更新本隐私政策。重大变更时,我们会在应用内通知您。继续使用服务即表示您接受更新后的隐私政策。
</p>
<h2>10. 联系我们</h2>
<div class="contact">
<h3>如果您对本隐私政策有任何问题或疑虑,请通过以下方式联系我们:</h3>
<p><strong>邮箱</strong><a href="mailto:noahbreak859@gmail.com">noahbreak859@gmail.com</a></p>
</div>
<p style="margin-top: 30px; color: #7f8c8d; font-size: 14px;">
本隐私政策自 2026年2月8日 起生效。
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,473 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>节点内容批量录入工具 - Wild Growth</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.content {
padding: 30px;
}
.form-group {
margin-bottom: 25px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
font-size: 14px;
}
input, textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, textarea:focus {
outline: none;
border-color: #f5576c;
}
textarea {
min-height: 300px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 20px;
}
button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(245, 87, 108, 0.4);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.result {
margin-top: 25px;
padding: 20px;
border-radius: 8px;
display: none;
}
.result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.result.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.result pre {
margin-top: 10px;
padding: 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
}
.example {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 25px;
border-left: 4px solid #f5576c;
}
.example h3 {
margin-bottom: 12px;
color: #f5576c;
font-size: 16px;
}
.example code {
display: block;
background: white;
padding: 12px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 12px;
overflow-x: auto;
white-space: pre;
}
.info-box {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 15px;
margin-bottom: 25px;
color: #856404;
}
.info-box strong {
display: block;
margin-bottom: 8px;
}
.info-box ul {
margin-left: 20px;
margin-top: 8px;
}
.info-box li {
margin-bottom: 4px;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #666;
transition: all 0.3s;
}
.tab.active {
color: #f5576c;
border-bottom-color: #f5576c;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📄 节点内容批量录入工具</h1>
<p>Wild Growth - 批量创建节点幻灯片内容</p>
</div>
<div class="content">
<div class="info-box">
<strong>📋 使用说明:</strong>
<ul>
<li>1. 填写节点 IDnode_01_01</li>
<li>2. 填写 JWT Token从登录接口获取</li>
<li>3. 在 JSON 编辑器中输入幻灯片数据</li>
<li>4. 点击"提交创建"按钮</li>
<li>5. 查看创建结果</li>
</ul>
</div>
<div class="form-group">
<label for="apiUrl">API 地址</label>
<input type="text" id="apiUrl" value="http://localhost:3000" placeholder="http://localhost:3000">
</div>
<div class="form-group">
<label for="nodeId">节点 ID *</label>
<input type="text" id="nodeId" value="node_01_01" placeholder="node_01_01" required>
</div>
<div class="form-group">
<label for="token">JWT Token *</label>
<input type="text" id="token" placeholder="从登录接口获取的 token" required>
</div>
<div class="example">
<h3>📝 JSON 数据格式示例:</h3>
<code>{
"slides": [
{
"id": "node_01_01_slide_01",
"slideType": "text",
"order": 1,
"title": "痛苦的根源",
"paragraphs": [
"痛苦,往往不是来自外界,而是来自我们内心的<span class=\"highlight\">认知偏差</span>。",
"当我们认为\"应该\"发生的事情没有发生时,痛苦就产生了。",
"但真相是:世界不会按照我们的期望运转,痛苦是成长的信号。"
],
"highlightKeywords": ["认知偏差"],
"effect": "fade_in"
},
{
"id": "node_01_01_slide_02",
"slideType": "image",
"order": 2,
"title": null,
"paragraphs": [
"这张卡片没有大标题。",
"而且你会发现,为了配合视觉流,图片被安排在了文字的下方。"
],
"imageUrl": "https://example.com/image.jpg",
"imagePosition": "bottom",
"effect": "fade_in"
},
{
"id": "node_01_01_slide_03",
"slideType": "text",
"order": 3,
"title": "本节小结",
"paragraphs": [
"1. 痛苦是认知偏差的信号",
"2. 调整期望比改变现实更容易",
"3. 接受现实,才能开始成长"
],
"effect": "fade_in"
}
]
}</code>
</div>
<div class="form-group">
<label for="jsonData">幻灯片数据 (JSON) *</label>
<textarea id="jsonData" placeholder='请输入 JSON 数据...'></textarea>
</div>
<div class="button-group">
<button class="btn-secondary" onclick="loadExample()">加载示例数据</button>
<button class="btn-secondary" onclick="formatJSON()">格式化 JSON</button>
<button class="btn-primary" onclick="submitData()">提交创建</button>
</div>
<div id="result" class="result"></div>
</div>
</div>
<script>
function loadExample() {
const example = {
slides: [
{
id: "node_01_01_slide_01",
slideType: "text",
order: 1,
title: "痛苦的根源",
paragraphs: [
"痛苦,往往不是来自外界,而是来自我们内心的<span class=\"highlight\">认知偏差</span>。",
"当我们认为\"应该\"发生的事情没有发生时,痛苦就产生了。",
"但真相是:世界不会按照我们的期望运转,痛苦是成长的信号。"
],
highlightKeywords: ["认知偏差"],
effect: "fade_in"
},
{
id: "node_01_01_slide_02",
slideType: "text",
order: 2,
title: "重新定义痛苦",
paragraphs: [
"痛苦 = 现实与期望的差距",
"缩小这个差距有两种方式:",
"1. 改变现实(往往很难)",
"2. 调整期望(更容易,也更智慧)"
],
effect: "fade_in"
},
{
id: "node_01_01_slide_03",
slideType: "image",
order: 3,
title: null,
paragraphs: [
"这张卡片没有大标题。",
"而且你会发现,为了配合视觉流,图片被安排在了文字的下方。"
],
imageUrl: "https://example.com/image.jpg",
imagePosition: "bottom",
effect: "fade_in"
},
{
id: "node_01_01_slide_04",
slideType: "text",
order: 4,
title: "本节小结",
paragraphs: [
"1. 痛苦是认知偏差的信号",
"2. 调整期望比改变现实更容易",
"3. 接受现实,才能开始成长"
],
effect: "fade_in"
}
]
};
document.getElementById('jsonData').value = JSON.stringify(example, null, 2);
}
function formatJSON() {
const textarea = document.getElementById('jsonData');
try {
const data = JSON.parse(textarea.value);
textarea.value = JSON.stringify(data, null, 2);
showResult('success', 'JSON 格式化成功!');
} catch (e) {
showResult('error', 'JSON 格式错误:' + e.message);
}
}
async function submitData() {
const apiUrl = document.getElementById('apiUrl').value.trim();
const nodeId = document.getElementById('nodeId').value.trim();
const token = document.getElementById('token').value.trim();
const jsonData = document.getElementById('jsonData').value.trim();
if (!nodeId || !token || !jsonData) {
showResult('error', '请填写所有必填字段!');
return;
}
let data;
try {
data = JSON.parse(jsonData);
} catch (e) {
showResult('error', 'JSON 格式错误:' + e.message);
return;
}
// 验证数据结构
if (!data.slides || !Array.isArray(data.slides)) {
showResult('error', 'JSON 数据格式错误:必须包含 slides 数组');
return;
}
const url = `${apiUrl}/api/courses/nodes/${nodeId}/slides`;
try {
showResult('success', '正在提交...', true);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
showResult('success', `✅ 创建成功!\n\n节点 ID${result.data.node_id}\n幻灯片数${result.data.slides_created}`, false, result);
} else {
showResult('error', `❌ 创建失败:${result.message || '未知错误'}`, false, result);
}
} catch (error) {
showResult('error', `❌ 请求失败:${error.message}`);
}
}
function showResult(type, message, loading = false, data = null) {
const resultDiv = document.getElementById('result');
resultDiv.className = `result ${type}`;
resultDiv.style.display = 'block';
let content = `<strong>${message}</strong>`;
if (data) {
content += `<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
resultDiv.innerHTML = content;
if (!loading) {
resultDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// 页面加载时自动加载示例数据
window.onload = function() {
loadExample();
};
</script>
</body>
</html>

195
backend/public/support.html Normal file
View File

@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>技术支持 - 电子成长</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #7f8c8d;
font-size: 16px;
margin-bottom: 30px;
}
h2 {
color: #34495e;
margin-top: 30px;
margin-bottom: 15px;
font-size: 20px;
border-bottom: 2px solid #3498db;
padding-bottom: 5px;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.contact-box {
background: #ecf0f1;
padding: 20px;
border-radius: 5px;
margin-top: 30px;
margin-bottom: 30px;
}
.contact-box h3 {
color: #2c3e50;
margin-bottom: 15px;
}
.contact-box p {
margin-bottom: 10px;
}
.email {
color: #3498db;
font-weight: bold;
font-size: 18px;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.faq-item {
margin-bottom: 20px;
}
.faq-question {
font-weight: bold;
color: #2c3e50;
margin-bottom: 8px;
}
.faq-answer {
color: #555;
margin-left: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>技术支持</h1>
<p class="subtitle">电子成长 - AI 驱动的知识学习应用</p>
<h2>关于我们</h2>
<p>
电子成长是一款 AI 驱动的知识学习应用。输入任何你想学的主题AI 自动生成交互式学习卡片,帮助你轻松掌握新知识。同时提供精选课程库,涵盖求职面试、自我提升、读书解读等热门话题。
</p>
<div class="contact-box">
<h3>📧 联系我们</h3>
<p>如果您在使用过程中遇到任何问题,或有任何建议和反馈,欢迎通过以下方式联系我们:</p>
<p>
<strong>技术支持邮箱:</strong><br>
<span class="email">noahbreak859@gmail.com</span>
</p>
<p style="margin-top: 15px;">
我们会在收到您的邮件后尽快回复,通常在 1-2 个工作日内给您答复。
</p>
</div>
<h2>常见问题</h2>
<div class="faq-item">
<div class="faq-question">Q: 不登录可以使用吗?</div>
<div class="faq-answer">
A: 可以。电子成长支持游客模式,您无需注册即可浏览发现页的精选课程。登录后可以使用 AI 创建课程、保存学习进度、创建笔记等完整功能。
</div>
</div>
<div class="faq-item">
<div class="faq-question">Q: 如何注册账号?</div>
<div class="faq-answer">
A: 您可以使用手机号验证码登录,或使用 Apple ID 登录。首次登录时系统会自动为您创建账号。
</div>
</div>
<div class="faq-item">
<div class="faq-question">Q: 忘记密码怎么办?</div>
<div class="faq-answer">
A: 电子成长采用手机号验证码登录,无需设置密码。如果您使用 Apple ID 登录,请使用 Apple 提供的密码恢复功能。
</div>
</div>
<div class="faq-item">
<div class="faq-question">Q: AI 创建课程是怎么回事?</div>
<div class="faq-answer">
A: 输入任何你想学的主题(如"如何提升写作能力"选择一位讲解老师AI 会自动为你生成一套结构化的交互式学习卡片。你也可以粘贴一段文字或文档AI 会将其转化为易于学习的内容。
</div>
</div>
<div class="faq-item">
<div class="faq-question">Q: 学习进度会同步吗?</div>
<div class="faq-answer">
A: 是的,登录后您的学习进度会保存在云端,可以在不同设备间同步。
</div>
</div>
<div class="faq-item">
<div class="faq-question">Q: 如何注销账号?</div>
<div class="faq-answer">
A: 您可以在 App 内"我的"页面,点击右上角"账户",选择"注销账号"。请注意,注销账号是不可逆的操作,将永久删除您的所有数据。
</div>
</div>
<div class="faq-item">
<div class="faq-question">Q: App 是否收费?</div>
<div class="faq-answer">
A: 电子成长目前完全免费使用,所有功能均可免费体验。
</div>
</div>
<div class="faq-item">
<div class="faq-question">Q: 如何反馈问题或建议?</div>
<div class="faq-answer">
A: 请发送邮件至 noahbreak859@gmail.com我们会认真对待每一条反馈持续改进产品体验。
</div>
</div>
<h2>隐私和安全</h2>
<p>
我们非常重视您的隐私和数据安全。有关我们如何收集、使用和保护您的个人信息,请参阅我们的
<a href="/privacy-policy.html">隐私政策</a>
</p>
<p>
有关使用条款和条件,请参阅我们的
<a href="/user-agreement.html">用户服务协议</a>
</p>
<div class="contact-box" style="margin-top: 40px;">
<p style="text-align: center; color: #7f8c8d; margin-bottom: 0;">
© 2026 电子成长. 保留所有权利.
</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户服务协议 - 电子成长</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
font-size: 28px;
}
.last-updated {
color: #7f8c8d;
font-size: 14px;
margin-bottom: 30px;
}
h2 {
color: #34495e;
margin-top: 30px;
margin-bottom: 15px;
font-size: 20px;
border-bottom: 2px solid #3498db;
padding-bottom: 5px;
}
h3 {
color: #34495e;
margin-top: 20px;
margin-bottom: 10px;
font-size: 16px;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.contact {
background: #ecf0f1;
padding: 20px;
border-radius: 5px;
margin-top: 30px;
}
.contact h3 {
color: #2c3e50;
margin-bottom: 10px;
}
.contact p {
margin-bottom: 5px;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.highlight {
background: #fff3cd;
padding: 15px;
border-left: 4px solid #ffc107;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>用户服务协议</h1>
<p class="last-updated">最后更新时间2026年2月8日</p>
<div class="highlight">
<p><strong>重要提示:</strong>请仔细阅读本协议。使用「电子成长」应用即表示您同意接受本协议的所有条款。如果您不同意本协议的任何内容,请不要使用本应用。</p>
</div>
<h2>1. 协议的接受</h2>
<p>
欢迎使用「电子成长」(以下简称"本应用")。本用户服务协议(以下简称"本协议")是您与本应用运营方之间关于使用本应用的法律协议。通过下载、安装、访问或使用本应用,您表示同意受本协议约束。
</p>
<h2>2. 服务描述</h2>
<p>
电子成长是一款 AI 驱动的知识学习应用,致力于帮助用户将任意主题或文本转化为可学习的卡片式课程。我们提供以下核心功能:
</p>
<ul>
<li><strong>发现</strong>:浏览编辑推荐的公开课程与运营位内容</li>
<li><strong>AI 创建课程</strong>:输入任意主题,选择讲解老师风格(如小红学姐、林老师、丁老师),由 AI 自动生成结构化卡片课程</li>
<li><strong>文本解析</strong>粘贴或上传文档AI 将其解析为可学习的课程</li>
<li><strong>续旧课</strong>:基于已有课程内容继续扩展</li>
<li><strong>课程学习</strong>:竖屏卡片式学习体验,按小节学习并记录进度</li>
<li><strong>背包(内容管理)</strong>:管理已加入和已创建的课程</li>
<li><strong>笔记</strong>:在学习过程中创建笔记,支持笔记本分类管理</li>
<li><strong>个人中心</strong>:头像、昵称、学习统计等</li>
</ul>
<p>
您可以以<strong>游客模式</strong>浏览发现页的公开课程登录后可使用完整功能AI 创建课程、笔记、学习进度同步等)。
</p>
<h2>3. 账户注册和使用</h2>
<h3>3.1 账户与登录</h3>
<ul>
<li>您可通过<strong>手机号验证码</strong><strong>Apple 登录</strong>注册和登录账户</li>
<li>未登录时,您可以游客模式浏览公开课程;登录后可使用 AI 创建课程、笔记、学习进度同步等完整功能</li>
<li>您应提供真实、准确的信息,并保护账户安全,对账户下的所有活动负责</li>
<li>您不得与他人共享账户或允许他人使用您的账户</li>
</ul>
<h3>3.2 使用规则</h3>
<p>您同意不会:</p>
<ul>
<li>以任何非法方式使用本应用</li>
<li>干扰或破坏本应用或相关服务器</li>
<li>尝试未经授权访问本应用或相关系统</li>
<li>复制、修改、分发、出售或租赁本应用或其任何部分</li>
<li>使用自动化工具(如机器人、爬虫)访问本应用</li>
<li>传播病毒、恶意代码或其他有害内容</li>
<li>利用 AI 创建功能生成违反法律法规或公序良俗的内容</li>
</ul>
<h2>4. AI 生成内容</h2>
<h3>4.1 内容生成</h3>
<p>
本应用使用人工智能技术根据您提供的主题或文本生成课程内容。AI 生成的内容仅供学习参考,不保证其准确性、完整性或时效性。您应对 AI 生成内容的使用做出独立判断。
</p>
<h3>4.2 并发限制</h3>
<p>
为保障服务质量,每位用户同时进行中的 AI 课程生成任务数量有限。请在当前任务完成后再创建新的生成任务。
</p>
<h2>5. 知识产权</h2>
<p>
本应用及其所有原创内容包括但不限于界面设计、图标、代码、文案的知识产权归本应用运营方所有。AI 根据您的输入生成的课程内容,您可在本应用内自由学习和使用。未经我们明确书面许可,您不得将应用内容用于商业用途。
</p>
<h2>6. 用户生成内容</h2>
<p>
您在本应用中提交的内容(包括但不限于 AI 创建课程时输入的主题或文本、学习笔记),您授予我们非独占、免版税的许可,以使用这些内容用于提供和改进服务。我们不会将您的个人输入内容用于与提供服务无关的营销活动或对外公开共享。
</p>
<h2>7. 免责声明</h2>
<p>
本应用按"现状"提供。我们不保证:
</p>
<ul>
<li>应用将始终可用、无错误或安全</li>
<li>应用将满足您的所有需求</li>
<li>AI 生成的课程内容准确、完整或适合您的具体学习场景</li>
</ul>
<p>
<strong>本应用提供的内容仅供学习参考,不构成专业建议。对于因使用或无法使用本应用而产生的任何损害,我们不承担责任。</strong>
</p>
<h2>8. 责任限制</h2>
<p>
在法律允许的最大范围内,本应用运营方不对因使用或无法使用本应用而产生的任何间接、偶然、特殊、后果性或惩罚性损害承担责任。
</p>
<h2>9. 服务变更和终止</h2>
<h3>9.1 服务变更</h3>
<p>
我们保留随时修改、暂停或终止本应用或其任何部分的权利,无需提前通知。
</p>
<h3>9.2 账户终止</h3>
<p>
如果您违反本协议,我们保留立即终止或暂停您账户的权利。您也可以随时通过应用内的"注销账号"功能或联系我们来删除您的账户。
</p>
<h2>10. 隐私</h2>
<p>
我们非常重视您的隐私。有关我们如何收集、使用和保护您的个人信息,请参阅我们的<a href="/privacy-policy.html">隐私政策</a>
</p>
<h2>11. 协议修改</h2>
<p>
我们可能会不时更新本协议。重大变更时,我们会在应用内通知您。继续使用服务即表示您接受更新后的协议。如果您不同意修改后的协议,请停止使用本应用。
</p>
<h2>12. 适用法律</h2>
<p>
本协议受中华人民共和国法律管辖。因本协议引起的任何争议,双方应友好协商解决;协商不成的,应提交有管辖权的人民法院解决。
</p>
<h2>13. 其他条款</h2>
<ul>
<li>如果本协议的任何条款被认定为无效或不可执行,其余条款仍然有效</li>
<li>本协议构成您与我们之间关于使用本应用的完整协议</li>
<li>我们未行使本协议中的任何权利不构成对该权利的放弃</li>
</ul>
<h2>14. 联系我们</h2>
<div class="contact">
<h3>如果您对本协议有任何问题,请通过以下方式联系我们:</h3>
<p><strong>邮箱</strong><a href="mailto:noahbreak859@gmail.com">noahbreak859@gmail.com</a></p>
</div>
<p style="margin-top: 30px; color: #7f8c8d; font-size: 14px;">
本协议自 2026年2月8日 起生效。
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>小红书爆款封面模板 · Playground</title>
<style>
* { box-sizing: border-box; }
body {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0;
padding: 24px 16px 48px;
background: #f5f5f5;
color: #333;
}
h1 {
text-align: center;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 8px;
}
.sub {
text-align: center;
color: #666;
font-size: 0.9rem;
margin-bottom: 24px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
max-width: 1200px;
margin: 0 auto;
}
.card {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.card img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3/4;
object-fit: cover;
}
.card .name {
padding: 12px 16px;
font-weight: 600;
font-size: 1rem;
}
.card a {
display: block;
padding: 8px 16px 12px;
font-size: 0.85rem;
color: #2266FF;
text-decoration: none;
}
.card a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>小红书爆款封面模板</h1>
<p class="sub">共 21 套模板(可直接实现 + 部分实现),点击「大图 / 自定义文案」可打开 API 链接并传 ?text=行1|行2 自定义文案</p>
<div class="grid" id="list"></div>
<script>
var templates = [
{ id: 'grid-card', name: '网格卡片' },
{ id: 'minimal-white', name: '极简白底' },
{ id: 'gradient-soft', name: '渐变柔粉' },
{ id: 'memo-note', name: '苹果备忘录' },
{ id: 'dark-mode', name: '深色暗黑' },
{ id: 'pastel-cute', name: '奶油胶可爱' },
{ id: 'magazine', name: '杂志分栏' },
{ id: 'retro-study', name: '复古学习' },
{ id: 'fresh-melon', name: '青提甜瓜' },
{ id: 'sunset', name: '日落黄昏' },
{ id: 'gradient-blue', name: '渐变蓝' },
{ id: 'quote-card', name: '引用卡片' },
{ id: 'quality-solitude', name: '低质量社交不如高质量独处' },
{ id: 'text-note-orange', name: '橙色便签 Text Note' },
{ id: 'quote-pink', name: '粉色引号' },
{ id: 'pixel-note', name: '像素风 note' },
{ id: 'keyword-style', name: '关键词型 纯干货保姆级' },
{ id: 'literary-quote', name: '文艺书摘' },
{ id: 'fresh-oxygen', name: '清新氧气感' },
{ id: 'fashion-trendy', name: '时尚潮酷' },
{ id: 'cute-cartoon', name: '可爱卡通' },
];
var list = document.getElementById('list');
templates.forEach(function (t) {
var imgUrl = '/xhs-covers/' + t.id + '.png';
var bigUrl = '/api/playground/xhs-cover/' + encodeURIComponent(t.id);
var card = document.createElement('div');
card.className = 'card';
card.innerHTML =
'<img src="' + imgUrl + '" alt="' + t.name + '" loading="lazy" onerror="this.src=\'' + bigUrl + '\'" />' +
'<div class="name">' + t.name + '</div>' +
'<a href="' + bigUrl + '" target="_blank" rel="noopener">大图 / 自定义文案</a>';
list.appendChild(card);
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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