commit 3c3179ef7de8bcd903b49c1e9b207de5fc9dca7e Author: wendazhi Date: Wed Feb 11 15:26:03 2026 +0800 init1 diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..c52aa1a --- /dev/null +++ b/.gitea/workflows/README.md @@ -0,0 +1,144 @@ +# Gitea Actions 部署配置说明 + +## 文件说明 + +- `online-deploy.yml` - 生产环境部署 workflow(main 分支和 tag) +- `develop-deploy.yml` - 开发环境部署 workflow(develop 分支) + +## 配置步骤 + +### 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` diff --git a/.gitea/workflows/online-deploy.yml b/.gitea/workflows/online-deploy.yml new file mode 100644 index 0000000..45cd828 --- /dev/null +++ b/.gitea/workflows/online-deploy.yml @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d565ab2 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 0000000..c1674d1 --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,3 @@ +API_BASE_URL=https://api.muststudy.xin +TEST_USER_EMAIL=test@example.com +TEST_USER_PASSWORD=test123456 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..5831137 --- /dev/null +++ b/backend/.gitignore @@ -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/ + + + + + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1bd3624 --- /dev/null +++ b/backend/README.md @@ -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登录配置 + + + + + diff --git a/backend/check_slides.js b/backend/check_slides.js new file mode 100644 index 0000000..0164476 --- /dev/null +++ b/backend/check_slides.js @@ -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(''); + const hasEmptyColor = (para.includes('')) || + (para.includes('')); + const hasEmptySpan = para.includes(''); + + // 检查标签格式问题 + const hasColorWithoutType = para.includes('&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 "✅ 检查完成!" + diff --git a/backend/deploy/check-notes-table.js b/backend/deploy/check-notes-table.js new file mode 100644 index 0000000..f8ba412 --- /dev/null +++ b/backend/deploy/check-notes-table.js @@ -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(); diff --git a/backend/deploy/create-notes-table.js b/backend/deploy/create-notes-table.js new file mode 100644 index 0000000..8e1580f --- /dev/null +++ b/backend/deploy/create-notes-table.js @@ -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(); diff --git a/backend/deploy/deploy-course-generation-refactor.sh b/backend/deploy/deploy-course-generation-refactor.sh new file mode 100755 index 0000000..b0f409c --- /dev/null +++ b/backend/deploy/deploy-course-generation-refactor.sh @@ -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 "" diff --git a/backend/deploy/deploy-from-github.sh b/backend/deploy/deploy-from-github.sh new file mode 100755 index 0000000..6059b1c --- /dev/null +++ b/backend/deploy/deploy-from-github.sh @@ -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 -5,checkout 后重新 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 pull(SKIP_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 "" diff --git a/backend/deploy/deploy-rsync-from-local.sh b/backend/deploy/deploy-rsync-from-local.sh new file mode 100644 index 0000000..1b38963 --- /dev/null +++ b/backend/deploy/deploy-rsync-from-local.sh @@ -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 "═══════════════════════════════════════════════════════════" diff --git a/backend/deploy/fix-notes-constraints.js b/backend/deploy/fix-notes-constraints.js new file mode 100644 index 0000000..0e480c8 --- /dev/null +++ b/backend/deploy/fix-notes-constraints.js @@ -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(); diff --git a/backend/deploy/scripts/migration-fix-and-start.sh b/backend/deploy/scripts/migration-fix-and-start.sh new file mode 100644 index 0000000..dc5ec30 --- /dev/null +++ b/backend/deploy/scripts/migration-fix-and-start.sh @@ -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 "═══════════════════════════════════════════════════════════" diff --git a/backend/deploy/scripts/server-cleanup-safe.sh b/backend/deploy/scripts/server-cleanup-safe.sh new file mode 100755 index 0000000..5296899 --- /dev/null +++ b/backend/deploy/scripts/server-cleanup-safe.sh @@ -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 释放约 5M(git pull 会拉回)" +echo "═══════════════════════════════════════════════════════════" diff --git a/backend/deploy/setup-apple-secret.sh b/backend/deploy/setup-apple-secret.sh new file mode 100755 index 0000000..cd69eba --- /dev/null +++ b/backend/deploy/setup-apple-secret.sh @@ -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. 使用沙盒账号进行测试" diff --git a/backend/deploy/setup-ssl-api.sh b/backend/deploy/setup-ssl-api.sh new file mode 100755 index 0000000..13b8d89 --- /dev/null +++ b/backend/deploy/setup-ssl-api.sh @@ -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 "" diff --git a/backend/deploy/update-nginx-ssl.sh b/backend/deploy/update-nginx-ssl.sh new file mode 100755 index 0000000..ac5dfca --- /dev/null +++ b/backend/deploy/update-nginx-ssl.sh @@ -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 "" diff --git a/backend/env.production.template b/backend/env.production.template new file mode 100644 index 0000000..4d052c6 --- /dev/null +++ b/backend/env.production.template @@ -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= + diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..5c430b7 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,29 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/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: { + '^@/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], + testTimeout: 30000, // 30秒超时(用于AI和向量化测试) + extensionsToTreatAsEsm: ['.ts'], + globals: { + 'ts-jest': { + useESM: false, + }, + }, +}; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..560a850 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,10899 @@ +{ + "name": "backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "1.0.0", + "license": "ISC", + "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" + } + }, + "node_modules/@alicloud/pop-core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@alicloud/pop-core/-/pop-core-1.8.0.tgz", + "integrity": "sha512-ef6vIVigtr9n8Lw6Ld2GZ9jVUD0+ReHviaQaMqZDPI2HwdpVvrq1Rvn2tBnFToe0tdTpovz9N7XFSf/C274OtA==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "httpx": "^2.1.2", + "json-bigint": "^1.0.0", + "kitx": "^1.2.1", + "xml2js": "^0.5.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@alicloud/pop-core/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz", + "integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", + "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", + "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", + "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", + "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/fetch-engine": "6.19.0", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", + "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", + "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", + "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.47", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", + "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/ali-oss": { + "version": "6.23.2", + "resolved": "https://registry.npmjs.org/@types/ali-oss/-/ali-oss-6.23.2.tgz", + "integrity": "sha512-j3n+kskvDpceQjnf4tA2pEagneSOdAS6oLQ9lnhpn4ipTvvN8i8iAF1y5Pn2g0/xREOsPXBhCbS0ox3hwuaOBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/axios": { + "version": "0.9.36", + "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.9.36.tgz", + "integrity": "sha512-NLOpedx9o+rxo/X5ChbdiX6mS1atE4WHmEEIcR9NLenRVa5HoVjAvjafwU3FPTqnZEstpoqCaW7fagqSoTDNeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pdf-parse": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz", + "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", + "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz", + "integrity": "sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==", + "license": "MIT", + "engines": { + "node": ">=0.3.0" + } + }, + "node_modules/agentkeepalive": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-3.5.3.tgz", + "integrity": "sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ali-oss": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/ali-oss/-/ali-oss-6.23.0.tgz", + "integrity": "sha512-FipRmyd16Pr/tEey/YaaQ/24Pc3HEpLM9S1DRakEuXlSLXNIJnu1oJtHM53eVYpvW3dXapSjrip3xylZUTIZVQ==", + "license": "MIT", + "dependencies": { + "address": "^1.2.2", + "agentkeepalive": "^3.4.1", + "bowser": "^1.6.0", + "copy-to": "^2.0.1", + "dateformat": "^2.0.0", + "debug": "^4.3.4", + "destroy": "^1.0.4", + "end-or-error": "^1.0.1", + "get-ready": "^1.0.0", + "humanize-ms": "^1.2.0", + "is-type-of": "^1.4.0", + "js-base64": "^2.5.2", + "jstoxml": "^2.0.0", + "lodash": "^4.17.21", + "merge-descriptors": "^1.0.1", + "mime": "^2.4.5", + "platform": "^1.3.1", + "pump": "^3.0.0", + "qs": "^6.4.0", + "sdk-base": "^2.0.1", + "stream-http": "2.8.2", + "stream-wormhole": "^1.0.4", + "urllib": "^2.44.0", + "utility": "^1.18.0", + "xml2js": "^0.6.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ali-oss/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ali-oss/node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", + "optional": true + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bowser": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz", + "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz", + "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", + "integrity": "sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/default-user-agent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-user-agent/-/default-user-agent-1.0.0.tgz", + "integrity": "sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==", + "license": "MIT", + "dependencies": { + "os-name": "~1.0.3" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/digest-header": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/digest-header/-/digest-header-1.1.0.tgz", + "integrity": "sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==", + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/end-or-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/end-or-error/-/end-or-error-1.0.1.tgz", + "integrity": "sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==", + "license": "MIT", + "engines": { + "node": ">= 0.11.14" + } + }, + "node_modules/epub": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/epub/-/epub-1.3.0.tgz", + "integrity": "sha512-6BL8gIitljkTf4HW52Ast6wenPTkMKllU28bRc5awVsT+xCaPl6nWSaqSmHbRgPrl1+5uekOPvOxy7DQzbhM8Q==", + "dependencies": { + "adm-zip": "^0.4.11", + "xml2js": "^0.4.23" + }, + "optionalDependencies": { + "zipfile": "^0.5.11" + } + }, + "node_modules/epub/node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/formstream": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/formstream/-/formstream-1.5.2.tgz", + "integrity": "sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==", + "license": "MIT", + "dependencies": { + "destroy": "^1.0.4", + "mime": "^2.5.2", + "node-hex": "^1.0.1", + "pause-stream": "~0.0.11" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^2.6.0" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", + "optional": true, + "dependencies": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "optional": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "optional": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "optional": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-ready": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-ready/-/get-ready-1.0.0.tgz", + "integrity": "sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==", + "license": "MIT" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/httpx": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz", + "integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==", + "license": "MIT", + "dependencies": { + "@types/node": "^20", + "debug": "^4.1.1" + } + }, + "node_modules/httpx/node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/httpx/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/ignore-walk": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz", + "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minimatch": "^3.0.4" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "devOptional": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-class-hotfix": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/is-class-hotfix/-/is-class-hotfix-0.0.6.tgz", + "integrity": "sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-type-of": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/is-type-of/-/is-type-of-1.4.0.tgz", + "integrity": "sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==", + "license": "MIT", + "dependencies": { + "core-util-is": "^1.0.2", + "is-class-hotfix": "~0.0.6", + "isstream": "~0.1.2" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus/node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joi": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", + "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-base64": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", + "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", + "license": "BSD-3-Clause" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jstoxml": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/jstoxml/-/jstoxml-2.2.9.tgz", + "integrity": "sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kitx": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/kitx/-/kitx-1.3.0.tgz", + "integrity": "sha512-fhBqFlXd0GkKTB+8ayLfpzPUw+LHxZlPAukPNBD1Om7JMeInT+/PxCAf1yLagvD+VKoyWhXtJR68xQkX/a0wOQ==", + "license": "MIT" + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/mammoth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/mammoth/node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^2.9.0" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", + "optional": true, + "dependencies": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "optional": true + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nan": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "license": "MIT", + "optional": true + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-hex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-hex/-/node-hex-1.0.1.tgz", + "integrity": "sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-pre-gyp": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz", + "integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==", + "deprecated": "Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/node-pre-gyp/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/node-pre-gyp/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1", + "osenv": "^0.1.4" + }, + "bin": { + "nopt": "bin/nopt.js" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", + "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", + "license": "ISC", + "optional": true + }, + "node_modules/npm-packlist": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz", + "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", + "license": "ISC", + "optional": true, + "dependencies": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, + "node_modules/openai": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz", + "integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/os-name": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-1.0.3.tgz", + "integrity": "sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==", + "license": "MIT", + "dependencies": { + "osx-release": "^1.0.0", + "win-release": "^1.0.0" + }, + "bin": { + "os-name": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "node_modules/osx-release": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/osx-release/-/osx-release-1.1.0.tgz", + "integrity": "sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==", + "license": "MIT", + "dependencies": { + "minimist": "^1.1.0" + }, + "bin": { + "osx-release": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/pdf-parse": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", + "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + } + }, + "node_modules/pdf-parse/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", + "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.0", + "@prisma/engines": "6.19.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, + "node_modules/sdk-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/sdk-base/-/sdk-base-2.0.1.tgz", + "integrity": "sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==", + "license": "MIT", + "dependencies": { + "get-ready": "~1.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/sharp/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/sharp/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/sharp/node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/sharp/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/sharp/node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/sharp/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-http": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.2.tgz", + "integrity": "sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==", + "license": "MIT", + "dependencies": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/stream-http/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/stream-http/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/stream-http/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/stream-wormhole": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz", + "integrity": "sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tar": { + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" + }, + "engines": { + "node": ">=4.5" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", + "optional": true, + "dependencies": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "optional": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unescape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unescape/-/unescape-1.0.1.tgz", + "integrity": "sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urllib": { + "version": "2.44.0", + "resolved": "https://registry.npmjs.org/urllib/-/urllib-2.44.0.tgz", + "integrity": "sha512-zRCJqdfYllRDA9bXUtx+vccyRqtJPKsw85f44zH7zPD28PIvjMqIgw9VwoTLV7xTBWZsbebUFVHU5ghQcWku2A==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.3.0", + "content-type": "^1.0.2", + "default-user-agent": "^1.0.0", + "digest-header": "^1.0.0", + "ee-first": "~1.1.1", + "formstream": "^1.1.0", + "humanize-ms": "^1.2.0", + "iconv-lite": "^0.6.3", + "pump": "^3.0.0", + "qs": "^6.4.0", + "statuses": "^1.3.1", + "utility": "^1.16.1" + }, + "engines": { + "node": ">= 0.10.0" + }, + "peerDependencies": { + "proxy-agent": "^5.0.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, + "node_modules/urllib/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/urllib/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utility": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/utility/-/utility-1.18.0.tgz", + "integrity": "sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==", + "license": "MIT", + "dependencies": { + "copy-to": "^2.0.1", + "escape-html": "^1.0.3", + "mkdirp": "^0.5.1", + "mz": "^2.7.0", + "unescape": "^1.0.1" + }, + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/win-release": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/win-release/-/win-release-1.1.1.tgz", + "integrity": "sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==", + "license": "MIT", + "dependencies": { + "semver": "^5.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/win-release/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/winston": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", + "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zipfile": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/zipfile/-/zipfile-0.5.12.tgz", + "integrity": "sha512-zA60gW+XgQBu/Q4qV3BCXNIDRald6Xi5UOPj3jWGlnkjmBHaKDwIz7kyXWV3kq7VEsQN/2t/IWjdXdKeVNm6Eg==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "nan": "~2.10.0", + "node-pre-gyp": "~0.10.2" + }, + "bin": { + "unzip.js": "bin/unzip.js" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..127002c --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/playground-xhs-cover.png b/backend/playground-xhs-cover.png new file mode 100644 index 0000000..379d559 Binary files /dev/null and b/backend/playground-xhs-cover.png differ diff --git a/backend/prisma/add_two_single_courses.sql b/backend/prisma/add_two_single_courses.sql new file mode 100644 index 0000000..bc7b12d --- /dev/null +++ b/backend/prisma/add_two_single_courses.sql @@ -0,0 +1,151 @@ +-- 添加 2 个小节课测试数据 +-- 执行方式:psql -U your_username -d your_database -f add_two_single_courses.sql + +-- ============================================================ +-- 小节课 1:5分钟时间管理 +-- ============================================================ + +-- 插入小节课 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; + +-- ============================================================ +-- 小节课 2:3分钟学会专注 +-- ============================================================ + +-- 插入小节课 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; + diff --git a/backend/prisma/add_type_column_migration.sql b/backend/prisma/add_type_column_migration.sql new file mode 100644 index 0000000..8d57364 --- /dev/null +++ b/backend/prisma/add_type_column_migration.sql @@ -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 $$; + diff --git a/backend/prisma/add_vertical_screen_course.sql b/backend/prisma/add_vertical_screen_course.sql new file mode 100644 index 0000000..7f27e5d --- /dev/null +++ b/backend/prisma/add_vertical_screen_course.sql @@ -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": "

倾听的艺术

真正的沟通不是说话,而是倾听。学会倾听,是高效沟通的第一步。

为什么倾听如此重要?

很多人认为沟通就是表达自己的观点,但实际上,倾听才是沟通的核心。只有真正听懂对方,才能做出有效的回应。

倾听的三个层次

第一层:听到 - 你听到了对方的声音,但可能没有理解。

第二层:听懂 - 你理解了对方说的内容,知道了表面意思。

第三层:听透 - 你理解了对方的情绪、需求和背后的真实意图。

如何提升倾听能力?

1. 保持专注,避免分心

2. 用眼神和肢体语言表达关注

3. 不打断对方,让对方说完

4. 用提问确认理解,而不是急于回应

5. 关注对方的情绪,而不只是内容

"}'::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": "

表达的技巧

清晰、有力的表达能让你的观点更容易被接受。掌握表达的技巧,让沟通更高效。

结构化表达

好的表达需要清晰的结构。推荐使用金字塔原理:先结论,后原因,再案例。

结论先行 - 先说你的核心观点

分层说明 - 用3个要点支撑你的观点

案例佐证 - 用具体案例让观点更有说服力

语言的力量

用词的选择会直接影响沟通效果:

❌ \"我觉得可能这样会好一点\"

✅ \"我建议采用这个方案,原因有三点\"

用肯定的语言替代模糊的表达,会让你的观点更可信。

非语言沟通

除了语言,肢体语言也至关重要:

眼神接触 - 保持适度的眼神交流,表达自信

姿态 - 保持开放的身体姿态,不要交叉手臂

语速 - 控制语速,重要内容可以放慢强调

手势 - 适度的手势能增强表达力

"}'::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": "

冲突的处理

冲突是沟通中不可避免的。关键在于如何将冲突转化为建设性的对话。

理解冲突的本质

大多数冲突不是观点的对立,而是需求的不匹配。找到双方的真实需求,是解决冲突的关键。

冲突通常源于:

• 利益的不一致

• 价值观的差异

• 沟通的误解

• 情绪的干扰

处理冲突的三步法

第一步:冷静下来

情绪激动时不要沟通。给自己和对方一些时间,等情绪平复后再讨论。

第二步:理解对方

尝试站在对方的角度思考:\"如果我是他,为什么会这样想?\"理解对方的立场和需求。

第三步:寻找共赢

不要只想着\"我赢\",而是寻找\"我们都赢\"的解决方案。通常有第三种选择比妥协更好。

实用技巧

• 使用\"我\"的表达方式,而不是\"你\":\"我感到...\" 而不是 \"你总是...\"

• 关注问题本身,而不是攻击对方

• 承认对方的感受:\"我理解你的感受\"

• 寻找共同目标:\"我们都是为了...\"

• 如果无法解决,可以暂时搁置,之后再讨论

"}'::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; diff --git a/backend/prisma/insert-single-courses-server.sql b/backend/prisma/insert-single-courses-server.sql new file mode 100644 index 0000000..5f9eb60 --- /dev/null +++ b/backend/prisma/insert-single-courses-server.sql @@ -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 + +-- ============================================================ +-- 小节课 1:5分钟时间管理 +-- ============================================================ + +-- 插入小节课 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; + +-- ============================================================ +-- 小节课 2:3分钟学会专注 +-- ============================================================ + +-- 插入小节课 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; + diff --git a/backend/prisma/insert-single-courses.ts b/backend/prisma/insert-single-courses.ts new file mode 100644 index 0000000..ccb59a4 --- /dev/null +++ b/backend/prisma/insert-single-courses.ts @@ -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 { + // ============================================================ + // 小节课 1:5分钟时间管理 + // ============================================================ + 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分钟时间管理'); + + // ============================================================ + // 小节课 2:3分钟学会专注 + // ============================================================ + 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(); + }); + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..5e1f4ee --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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") // 所属笔记本 ID,nil 表示未分类 + parentId String? @map("parent_id") // 父笔记 ID,nil 表示顶级 + 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-based;null=单次 + 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 相关模型已删除 diff --git a/backend/prisma/schema_fixed.prisma b/backend/prisma/schema_fixed.prisma new file mode 100644 index 0000000..009495a --- /dev/null +++ b/backend/prisma/schema_fixed.prisma @@ -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") +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..ba4c5f6 --- /dev/null +++ b/backend/prisma/seed.ts @@ -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(); + }); diff --git a/backend/public/chunking-test.html b/backend/public/chunking-test.html new file mode 100644 index 0000000..ab8e65d --- /dev/null +++ b/backend/public/chunking-test.html @@ -0,0 +1,480 @@ + + + + + + 按章分块测试 + + + +
+

按章分块测试(批量)

+

支持批量上传文档,识别章级结构并分块

+ +
+ +
+
上传文档
+ +
+
📚
+
点击上传或拖拽文件
+
支持 Word、PDF、EPUB,可多选
+
单文件最大 100MB
+
+ + +
+ + + +
+ + +
+
+ + +
+
分块结果
+ +
+
+
📄
+
上传文档后点击"开始分块"
+
+
+
+
+
+ + + + diff --git a/backend/public/course-admin.html b/backend/public/course-admin.html new file mode 100644 index 0000000..cdee63d --- /dev/null +++ b/backend/public/course-admin.html @@ -0,0 +1,10786 @@ + + + + + + 课程管理系统 - Wild Growth + + + + + + + + + +
+
+

📚 课程管理系统

+

Wild Growth - 管理课程基本信息

+
+ +
+ + + + + +
+
+ + + + + diff --git a/backend/public/course-builder.html b/backend/public/course-builder.html new file mode 100644 index 0000000..869019f --- /dev/null +++ b/backend/public/course-builder.html @@ -0,0 +1,424 @@ + + + + + + 课程内容批量录入工具 - Wild Growth + + + +
+
+

📚 课程内容批量录入工具

+

Wild Growth - 批量创建章节和节点

+
+ +
+
+ 📋 使用说明: +
    +
  • 1. 填写课程 ID(如:course_001)
  • +
  • 2. 填写 JWT Token(从登录接口获取)
  • +
  • 3. 在 JSON 编辑器中输入章节和节点数据
  • +
  • 4. 点击"提交创建"按钮
  • +
  • 5. 查看创建结果
  • +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+

📝 JSON 数据格式示例:

+ { + "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 + } + ] + } + ] +} +
+ +
+ + +
+ +
+ + + +
+ +
+
+
+ + + + + + + + diff --git a/backend/public/course-sync.html b/backend/public/course-sync.html new file mode 100644 index 0000000..fd8a58c --- /dev/null +++ b/backend/public/course-sync.html @@ -0,0 +1,2175 @@ + + + + + + + 课程完整同步工具 - Wild Growth + + + +
+
+

📚 课程完整同步工具

+

Wild Growth - 一次性同步整个课程(课程信息 + 章节 + 节点 + 内容)

+
+ +
+ +
+ + +
+ + +
+
+ 📋 使用说明: +
    +
  • 1. 填写 API 地址(默认:https://api.muststudy.xin)
  • +
  • 2. 填写 JWT Token(从登录接口获取)
  • +
  • 3. 在 JSON 编辑器中输入完整的课程数据(包括课程信息、章节、节点、幻灯片)
  • +
  • 4. 点击"同步课程"按钮,工具会自动依次创建:课程 → 章节和节点 → 节点内容
  • +
  • 5. 查看同步结果和统计信息
  • +
+
+ +
+ + +
+ +
+ +
+ + +
+
💡 点击"快速获取 Token"按钮自动获取并填写
+
+ +
+ +
+ +
+
📤
+
点击选择图片(JPG/PNG/WebP,< 2MB)
+
或拖拽图片到此处
+
+ +
+
+
+ +
+

📝 JSON 数据格式示例:

+ { + "course": { + "id": "course_001", + "title": "认知觉醒", + "subtitle": "开启你的元认知之旅", + "description": "情绪急救与心理复原力构建。", + "coverImage": "brain.head.goforward.ar" + }, + "chapters": [ + { + "title": "第一章:重新认识大脑", + "order": 1, + "nodes": [ + { + "id": "node_01_01", + "title": "我们为什么会痛苦?", + "subtitle": "理解痛苦的根源", + "duration": 5, + "slides": [ + { + "type": "text", + "order": 1, + "content": { + "title": "思维的杠杆", + "paragraphs": [ + "阿基米德说:给我一个支点,我能撬动地球。" + ], + "highlightKeywords": ["杠杆率"] + } + } + ] + } + ] + } + ] +} +
+ +
+ +
+ +
+
+ +
+ + + + + +
+ + + + +
+
+ + +
+
+ 📸 封面图管理说明: +
    +
  • 1. 填写 API 地址和 JWT Token(与课程同步共用)
  • +
  • 2. 点击"加载课程列表"获取所有课程
  • +
  • 3. 为每个课程上传封面图(750x1000,3:4 比例)
  • +
  • 4. 上传后自动更新到数据库
  • +
+
+ +
+
+ + +
+ +
+ +
+ + +
+
💡 点击"快速获取 Token"按钮自动获取并填写
+
+ +
+ + +
+ +
+
+
+
+
+
+ + + + + diff --git a/backend/public/images/05de4335-bf14-4246-8e5a-7e0eed4b9211.png b/backend/public/images/05de4335-bf14-4246-8e5a-7e0eed4b9211.png new file mode 100644 index 0000000..0e8f12f Binary files /dev/null and b/backend/public/images/05de4335-bf14-4246-8e5a-7e0eed4b9211.png differ diff --git a/backend/public/images/0cde9f91-56e1-4242-8e36-f2f6638439da.jpg b/backend/public/images/0cde9f91-56e1-4242-8e36-f2f6638439da.jpg new file mode 100644 index 0000000..c278e05 Binary files /dev/null and b/backend/public/images/0cde9f91-56e1-4242-8e36-f2f6638439da.jpg differ diff --git a/backend/public/images/0ee6379a-859e-4757-8160-76b3e136c327.jpg b/backend/public/images/0ee6379a-859e-4757-8160-76b3e136c327.jpg new file mode 100644 index 0000000..e5d7037 Binary files /dev/null and b/backend/public/images/0ee6379a-859e-4757-8160-76b3e136c327.jpg differ diff --git a/backend/public/images/101e3d2d-a3e5-4b54-ba0c-8a52c4d38193.png b/backend/public/images/101e3d2d-a3e5-4b54-ba0c-8a52c4d38193.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/101e3d2d-a3e5-4b54-ba0c-8a52c4d38193.png differ diff --git a/backend/public/images/1081e30d-555d-4e67-8e98-5ad216ba7523.jpg b/backend/public/images/1081e30d-555d-4e67-8e98-5ad216ba7523.jpg new file mode 100644 index 0000000..31cc697 Binary files /dev/null and b/backend/public/images/1081e30d-555d-4e67-8e98-5ad216ba7523.jpg differ diff --git a/backend/public/images/119c62ff-c77c-4e37-8c87-dc91629f9e38.jpg b/backend/public/images/119c62ff-c77c-4e37-8c87-dc91629f9e38.jpg new file mode 100644 index 0000000..b32c5ba Binary files /dev/null and b/backend/public/images/119c62ff-c77c-4e37-8c87-dc91629f9e38.jpg differ diff --git a/backend/public/images/312ad38d-6d40-4100-8995-941355d292d1.png b/backend/public/images/312ad38d-6d40-4100-8995-941355d292d1.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/312ad38d-6d40-4100-8995-941355d292d1.png differ diff --git a/backend/public/images/3527b0aa-a12c-46d4-a2c2-ed64148c6720.jpg b/backend/public/images/3527b0aa-a12c-46d4-a2c2-ed64148c6720.jpg new file mode 100644 index 0000000..d795b02 Binary files /dev/null and b/backend/public/images/3527b0aa-a12c-46d4-a2c2-ed64148c6720.jpg differ diff --git a/backend/public/images/3c9ca871-1631-4497-be2c-d85b1fe199dd.png b/backend/public/images/3c9ca871-1631-4497-be2c-d85b1fe199dd.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/3c9ca871-1631-4497-be2c-d85b1fe199dd.png differ diff --git a/backend/public/images/42b5c51d-a6b0-4bc0-a306-aca57fb289e1.jpg b/backend/public/images/42b5c51d-a6b0-4bc0-a306-aca57fb289e1.jpg new file mode 100644 index 0000000..efa6485 Binary files /dev/null and b/backend/public/images/42b5c51d-a6b0-4bc0-a306-aca57fb289e1.jpg differ diff --git a/backend/public/images/472d0257-5003-465a-a762-cc8dded7ac1f.png b/backend/public/images/472d0257-5003-465a-a762-cc8dded7ac1f.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/472d0257-5003-465a-a762-cc8dded7ac1f.png differ diff --git a/backend/public/images/48b128a6-734f-450e-83c0-a01050f6093f.jpg b/backend/public/images/48b128a6-734f-450e-83c0-a01050f6093f.jpg new file mode 100644 index 0000000..d99edd2 Binary files /dev/null and b/backend/public/images/48b128a6-734f-450e-83c0-a01050f6093f.jpg differ diff --git a/backend/public/images/4fa1b7c4-1045-4836-a172-393876e12684.png b/backend/public/images/4fa1b7c4-1045-4836-a172-393876e12684.png new file mode 100644 index 0000000..8b1b98e Binary files /dev/null and b/backend/public/images/4fa1b7c4-1045-4836-a172-393876e12684.png differ diff --git a/backend/public/images/5165d8a1-b65a-4111-8867-d207fb8b054f.png b/backend/public/images/5165d8a1-b65a-4111-8867-d207fb8b054f.png new file mode 100644 index 0000000..4bb89bd Binary files /dev/null and b/backend/public/images/5165d8a1-b65a-4111-8867-d207fb8b054f.png differ diff --git a/backend/public/images/51c7b5cf-bfce-4089-8cf7-ee231ccad03a.jpg b/backend/public/images/51c7b5cf-bfce-4089-8cf7-ee231ccad03a.jpg new file mode 100644 index 0000000..44988b1 Binary files /dev/null and b/backend/public/images/51c7b5cf-bfce-4089-8cf7-ee231ccad03a.jpg differ diff --git a/backend/public/images/531190da-1928-4969-a4b4-e22a574f7ff0.png b/backend/public/images/531190da-1928-4969-a4b4-e22a574f7ff0.png new file mode 100644 index 0000000..8b1b98e Binary files /dev/null and b/backend/public/images/531190da-1928-4969-a4b4-e22a574f7ff0.png differ diff --git a/backend/public/images/675d6815-8793-4f8e-a396-ed558b7ee19a.jpg b/backend/public/images/675d6815-8793-4f8e-a396-ed558b7ee19a.jpg new file mode 100644 index 0000000..3cca3b8 Binary files /dev/null and b/backend/public/images/675d6815-8793-4f8e-a396-ed558b7ee19a.jpg differ diff --git a/backend/public/images/6d3c5cee-efc5-471b-b277-2a583e321001.jpg b/backend/public/images/6d3c5cee-efc5-471b-b277-2a583e321001.jpg new file mode 100644 index 0000000..47f8b50 Binary files /dev/null and b/backend/public/images/6d3c5cee-efc5-471b-b277-2a583e321001.jpg differ diff --git a/backend/public/images/79ed8758-395e-415b-94b6-96ee737fea86.jpg b/backend/public/images/79ed8758-395e-415b-94b6-96ee737fea86.jpg new file mode 100644 index 0000000..948aed3 Binary files /dev/null and b/backend/public/images/79ed8758-395e-415b-94b6-96ee737fea86.jpg differ diff --git a/backend/public/images/7b5c0dc4-ea3b-42c7-9eae-fa6bb2c2be0a.jpg b/backend/public/images/7b5c0dc4-ea3b-42c7-9eae-fa6bb2c2be0a.jpg new file mode 100644 index 0000000..4ee728d Binary files /dev/null and b/backend/public/images/7b5c0dc4-ea3b-42c7-9eae-fa6bb2c2be0a.jpg differ diff --git a/backend/public/images/7d85d480-a1bd-4a26-abb4-7f199d001610.jpg b/backend/public/images/7d85d480-a1bd-4a26-abb4-7f199d001610.jpg new file mode 100644 index 0000000..cc96a8e Binary files /dev/null and b/backend/public/images/7d85d480-a1bd-4a26-abb4-7f199d001610.jpg differ diff --git a/backend/public/images/81a43d15-24d6-4662-a6dd-0e47b6b2c94b.png b/backend/public/images/81a43d15-24d6-4662-a6dd-0e47b6b2c94b.png new file mode 100644 index 0000000..8b1b98e Binary files /dev/null and b/backend/public/images/81a43d15-24d6-4662-a6dd-0e47b6b2c94b.png differ diff --git a/backend/public/images/81e9638d-31d8-4191-8843-f0e708a49dc2.jpg b/backend/public/images/81e9638d-31d8-4191-8843-f0e708a49dc2.jpg new file mode 100644 index 0000000..164df33 Binary files /dev/null and b/backend/public/images/81e9638d-31d8-4191-8843-f0e708a49dc2.jpg differ diff --git a/backend/public/images/8527645d-8dcc-4a86-9617-b9c5b22f18be.jpg b/backend/public/images/8527645d-8dcc-4a86-9617-b9c5b22f18be.jpg new file mode 100644 index 0000000..72585ad Binary files /dev/null and b/backend/public/images/8527645d-8dcc-4a86-9617-b9c5b22f18be.jpg differ diff --git a/backend/public/images/85748416-d4f9-454d-8702-e23a133dc895.jpg b/backend/public/images/85748416-d4f9-454d-8702-e23a133dc895.jpg new file mode 100644 index 0000000..3b8d751 Binary files /dev/null and b/backend/public/images/85748416-d4f9-454d-8702-e23a133dc895.jpg differ diff --git a/backend/public/images/9003cb23-ffd9-49b8-a580-bb258247c184.jpg b/backend/public/images/9003cb23-ffd9-49b8-a580-bb258247c184.jpg new file mode 100644 index 0000000..c84997e Binary files /dev/null and b/backend/public/images/9003cb23-ffd9-49b8-a580-bb258247c184.jpg differ diff --git a/backend/public/images/914f3859-3a00-497b-8884-685bae6b5a12.jpg b/backend/public/images/914f3859-3a00-497b-8884-685bae6b5a12.jpg new file mode 100644 index 0000000..dc9738b Binary files /dev/null and b/backend/public/images/914f3859-3a00-497b-8884-685bae6b5a12.jpg differ diff --git a/backend/public/images/9551c509-2c92-440a-a0e0-28bd6633ab77.jpg b/backend/public/images/9551c509-2c92-440a-a0e0-28bd6633ab77.jpg new file mode 100644 index 0000000..983f897 Binary files /dev/null and b/backend/public/images/9551c509-2c92-440a-a0e0-28bd6633ab77.jpg differ diff --git a/backend/public/images/970347bf-f05c-450a-a268-ed52b50cb40f.jpg b/backend/public/images/970347bf-f05c-450a-a268-ed52b50cb40f.jpg new file mode 100644 index 0000000..877c0dd Binary files /dev/null and b/backend/public/images/970347bf-f05c-450a-a268-ed52b50cb40f.jpg differ diff --git a/backend/public/images/97bf3f5a-7123-445c-8b5a-9a25e79b52d8.jpg b/backend/public/images/97bf3f5a-7123-445c-8b5a-9a25e79b52d8.jpg new file mode 100644 index 0000000..708f107 Binary files /dev/null and b/backend/public/images/97bf3f5a-7123-445c-8b5a-9a25e79b52d8.jpg differ diff --git a/backend/public/images/9f01efb6-7c23-484e-b942-29e37499dfae.jpg b/backend/public/images/9f01efb6-7c23-484e-b942-29e37499dfae.jpg new file mode 100644 index 0000000..9c45588 Binary files /dev/null and b/backend/public/images/9f01efb6-7c23-484e-b942-29e37499dfae.jpg differ diff --git a/backend/public/images/9fecf57d-5c87-4b9c-9ffc-1b6383cefa6d.png b/backend/public/images/9fecf57d-5c87-4b9c-9ffc-1b6383cefa6d.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/9fecf57d-5c87-4b9c-9ffc-1b6383cefa6d.png differ diff --git a/backend/public/images/ac3414c5-6d51-4a87-92f7-5a72c3d6ecf8.png b/backend/public/images/ac3414c5-6d51-4a87-92f7-5a72c3d6ecf8.png new file mode 100644 index 0000000..ea52bbc Binary files /dev/null and b/backend/public/images/ac3414c5-6d51-4a87-92f7-5a72c3d6ecf8.png differ diff --git a/backend/public/images/af9b4a72-39bc-44ac-940b-434835b2912a.jpg b/backend/public/images/af9b4a72-39bc-44ac-940b-434835b2912a.jpg new file mode 100644 index 0000000..479406b Binary files /dev/null and b/backend/public/images/af9b4a72-39bc-44ac-940b-434835b2912a.jpg differ diff --git a/backend/public/images/b0a2c6ec-d84a-4cde-bb26-63e48f7f2c0f.jpg b/backend/public/images/b0a2c6ec-d84a-4cde-bb26-63e48f7f2c0f.jpg new file mode 100644 index 0000000..b29b61b Binary files /dev/null and b/backend/public/images/b0a2c6ec-d84a-4cde-bb26-63e48f7f2c0f.jpg differ diff --git a/backend/public/images/b0e38b89-b9e6-46b2-889d-f04ec91d9ed2.png b/backend/public/images/b0e38b89-b9e6-46b2-889d-f04ec91d9ed2.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/b0e38b89-b9e6-46b2-889d-f04ec91d9ed2.png differ diff --git a/backend/public/images/b3744688-0b7f-4db8-9f51-6cb86466bdd5.jpg b/backend/public/images/b3744688-0b7f-4db8-9f51-6cb86466bdd5.jpg new file mode 100644 index 0000000..ddca83f Binary files /dev/null and b/backend/public/images/b3744688-0b7f-4db8-9f51-6cb86466bdd5.jpg differ diff --git a/backend/public/images/b4eadee0-fa16-450c-a1d8-f64f9af0648d.png b/backend/public/images/b4eadee0-fa16-450c-a1d8-f64f9af0648d.png new file mode 100644 index 0000000..8b1b98e Binary files /dev/null and b/backend/public/images/b4eadee0-fa16-450c-a1d8-f64f9af0648d.png differ diff --git a/backend/public/images/bd33248a-6676-40fb-b0a5-fdd1df6d01a3.jpg b/backend/public/images/bd33248a-6676-40fb-b0a5-fdd1df6d01a3.jpg new file mode 100644 index 0000000..22e5877 Binary files /dev/null and b/backend/public/images/bd33248a-6676-40fb-b0a5-fdd1df6d01a3.jpg differ diff --git a/backend/public/images/bff11e39-3b7c-4063-b507-efa9f88b0cfe.png b/backend/public/images/bff11e39-3b7c-4063-b507-efa9f88b0cfe.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/bff11e39-3b7c-4063-b507-efa9f88b0cfe.png differ diff --git a/backend/public/images/c258ff32-856d-4bd8-8e1d-c511f3f5c15a.png b/backend/public/images/c258ff32-856d-4bd8-8e1d-c511f3f5c15a.png new file mode 100644 index 0000000..ea52bbc Binary files /dev/null and b/backend/public/images/c258ff32-856d-4bd8-8e1d-c511f3f5c15a.png differ diff --git a/backend/public/images/c4963fe5-0e02-445d-99bd-3682351b0e75.png b/backend/public/images/c4963fe5-0e02-445d-99bd-3682351b0e75.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/c4963fe5-0e02-445d-99bd-3682351b0e75.png differ diff --git a/backend/public/images/ce059240-1a54-4727-8c7d-ca188d723a0e.png b/backend/public/images/ce059240-1a54-4727-8c7d-ca188d723a0e.png new file mode 100644 index 0000000..8b1b98e Binary files /dev/null and b/backend/public/images/ce059240-1a54-4727-8c7d-ca188d723a0e.png differ diff --git a/backend/public/images/cfd98a81-5f29-4217-889d-3a7b189c8719.png b/backend/public/images/cfd98a81-5f29-4217-889d-3a7b189c8719.png new file mode 100644 index 0000000..0e8f12f Binary files /dev/null and b/backend/public/images/cfd98a81-5f29-4217-889d-3a7b189c8719.png differ diff --git a/backend/public/images/d43262a6-1075-4e59-bf4a-5ddde45781ed.png b/backend/public/images/d43262a6-1075-4e59-bf4a-5ddde45781ed.png new file mode 100644 index 0000000..ea52bbc Binary files /dev/null and b/backend/public/images/d43262a6-1075-4e59-bf4a-5ddde45781ed.png differ diff --git a/backend/public/images/dc046caf-35d0-4974-b6f9-8d5116071423.png b/backend/public/images/dc046caf-35d0-4974-b6f9-8d5116071423.png new file mode 100644 index 0000000..0e8f12f Binary files /dev/null and b/backend/public/images/dc046caf-35d0-4974-b6f9-8d5116071423.png differ diff --git a/backend/public/images/e29bb251-fb2e-4b66-abfd-8f80292ec8fe.png b/backend/public/images/e29bb251-fb2e-4b66-abfd-8f80292ec8fe.png new file mode 100644 index 0000000..ea52bbc Binary files /dev/null and b/backend/public/images/e29bb251-fb2e-4b66-abfd-8f80292ec8fe.png differ diff --git a/backend/public/images/ea0bde7e-b181-4218-badb-5c189047780f.jpg b/backend/public/images/ea0bde7e-b181-4218-badb-5c189047780f.jpg new file mode 100644 index 0000000..67412ea Binary files /dev/null and b/backend/public/images/ea0bde7e-b181-4218-badb-5c189047780f.jpg differ diff --git a/backend/public/images/f4fb19ae-5f94-4f92-b176-6b6457c93c32.jpg b/backend/public/images/f4fb19ae-5f94-4f92-b176-6b6457c93c32.jpg new file mode 100644 index 0000000..aac6fa1 Binary files /dev/null and b/backend/public/images/f4fb19ae-5f94-4f92-b176-6b6457c93c32.jpg differ diff --git a/backend/public/images/f82170fd-e41c-4455-bb8d-dc257172281a.jpg b/backend/public/images/f82170fd-e41c-4455-bb8d-dc257172281a.jpg new file mode 100644 index 0000000..f6f8aa0 Binary files /dev/null and b/backend/public/images/f82170fd-e41c-4455-bb8d-dc257172281a.jpg differ diff --git a/backend/public/images/fa7d1b94-65ef-48c5-904f-90ba507eae0c.png b/backend/public/images/fa7d1b94-65ef-48c5-904f-90ba507eae0c.png new file mode 100644 index 0000000..936eaf6 Binary files /dev/null and b/backend/public/images/fa7d1b94-65ef-48c5-904f-90ba507eae0c.png differ diff --git a/backend/public/index.html b/backend/public/index.html new file mode 100644 index 0000000..4672f9c --- /dev/null +++ b/backend/public/index.html @@ -0,0 +1,197 @@ + + + + + + 电子成长 - AI 驱动的知识学习应用 + + + + +
+
+

电子成长

+

无痛学习的魔法

+

AI 驱动的知识学习应用

+
+ +
+
+
🤖
+

AI 智能生成

+

输入任何你想学的主题,AI 自动生成结构化的交互式学习卡片,从入门到进阶一键搞定。

+
+
+
🎯
+

交互式卡片

+

精心设计的沉浸式学习体验,通过交互式卡片让复杂知识变得轻松易懂。

+
+
+
👩‍🏫
+

多种讲解风格

+

从轻松活泼到系统严谨,多位 AI 讲师供你选择,找到最适合自己的学习方式。

+
+
+
📚
+

精选课程库

+

涵盖求职面试、自我提升、读书解读等热门话题,每日更新精选内容。

+
+
+
📝
+

学习笔记

+

学习过程中随手记录灵感和要点,构建属于你自己的知识体系。

+
+
+
🔒
+

隐私保护

+

自建数据分析系统,不使用任何第三方追踪 SDK,您的数据安全是我们的底线。

+
+
+ +
+

开始你的成长之旅

+

免费下载,无需注册即可体验

+
+ + 在 App Store 下载 + +
+
+ + +
+ + diff --git a/backend/public/operational-banners.html b/backend/public/operational-banners.html new file mode 100644 index 0000000..aef67f8 --- /dev/null +++ b/backend/public/operational-banners.html @@ -0,0 +1,464 @@ + + + + + + 运营位管理 - Wild Growth + + + + +
+
+

运营位管理

+ ← 返回课程管理 +
+
+
+
+ + + +
+
+
+
+ + + + + + + + diff --git a/backend/public/privacy-policy.html b/backend/public/privacy-policy.html new file mode 100644 index 0000000..e5f96fc --- /dev/null +++ b/backend/public/privacy-policy.html @@ -0,0 +1,201 @@ + + + + + + 隐私政策 - 电子成长 + + + +
+

隐私政策

+

最后更新时间:2026年2月8日

+ +

1. 引言

+

+ 「电子成长」(以下简称"我们"或"本应用")非常重视您的隐私。本隐私政策说明了我们如何收集、使用、存储和保护您的个人信息。使用本应用即表示您同意本隐私政策的条款。 +

+ +

2. 我们收集的信息

+

2.1 您主动提供的信息

+
    +
  • 账户信息:当您注册或登录时,我们收集您的手机号码(用于验证码登录)或由 Apple 提供的用户标识符(如您使用 Sign in with Apple)
  • +
  • 个人资料:您可选择设置的昵称、头像等信息
  • +
  • 学习数据:您的课程学习进度、小节完成情况、学习时长等
  • +
  • 笔记内容:您在课程学习过程中创建的笔记及笔记本
  • +
  • AI 创建课程输入:您使用「AI 创建课程」功能时输入的主题或粘贴的文本,仅用于生成课程及改进服务
  • +
+ +

2.2 自动收集的信息

+

+ 为改进产品体验和排查技术问题,我们通过自建的轻量级埋点系统自动收集以下信息: +

+
    +
  • 设备信息:设备型号(如 iPhone15,2)、iOS 系统版本、App 版本号
  • +
  • 设备标识符:基于 identifierForVendor 生成的设备 ID,持久化存储于本地,用于区分匿名用户;不关联广告标识符(IDFA)
  • +
  • 会话信息:每次打开 App 生成的会话 ID,用于分析单次使用行为
  • +
  • 行为事件:页面浏览(如发现页、课程地图页)、功能使用(如 AI 创建课程、加入课程、创建笔记)、登录行为等,不包含您输入的具体文本内容
  • +
  • 网络类型:当前网络连接类型(如 Wi-Fi / 蜂窝网络)
  • +
+

+ 上述数据通过 HTTPS 加密传输至我们自有的服务器,不使用任何第三方数据分析 SDK(如 Firebase、友盟等),不与任何第三方共享。 +

+ +

3. 我们如何使用您的信息

+
    +
  • 提供、维护和改进核心服务(发现、课程学习、AI 创建课程、笔记等)
  • +
  • 同步您的学习进度、笔记和偏好设置
  • +
  • 使用您输入的主题或文本进行 AI 课程生成
  • +
  • 分析功能使用情况以改进产品体验(基于自建埋点数据)
  • +
  • 检测、预防和解决技术问题
  • +
  • 发送必要的服务通知(如安全警告)
  • +
+ +

4. 信息存储和安全

+

4.1 数据存储

+

+ 您的数据存储在我们自有的安全服务器上(位于中国大陆),我们采用以下安全措施保护您的信息: +

+
    +
  • 全链路 HTTPS 加密传输
  • +
  • 数据库访问权限控制
  • +
  • 敏感信息不明文存储
  • +
+ +

4.2 数据保留

+

+ 我们在您使用服务期间保留您的信息。当您注销账号后,我们将在合理时间内删除您的个人信息,法律要求保留的信息除外。埋点数据为匿名或半匿名数据,不直接关联您的真实身份。 +

+ +

4.3 离线缓存

+

+ 当网络不可用时,埋点数据会临时缓存在您的设备本地(最多 500 条),待网络恢复后自动上传并清除本地缓存。 +

+ +

5. 信息共享和披露

+

我们不会向第三方出售、交易或出租您的个人信息。我们仅在以下情况下共享您的信息:

+
    +
  • 服务提供商:与帮助我们运营服务的可信第三方(如云服务器提供商)共享必要的基础设施信息
  • +
  • 法律要求:当法律、法规或政府机关依法要求时
  • +
  • 保护权利:为保护我们的合法权利,或保护用户及公众的安全
  • +
+ +

6. 第三方服务

+

6.1 Apple 服务

+

+ 如果您使用 Sign in with Apple 登录,您的信息将按照 Apple 的隐私政策处理。我们仅接收 Apple 提供的用户标识符用于账户关联,不获取您的 Apple ID 密码或支付信息。 +

+ +

6.2 AI 服务

+

+ AI 课程生成功能使用第三方大语言模型 API。我们仅将您输入的主题或文本发送至 AI 服务提供商用于生成课程内容,不发送您的个人身份信息。具体的数据处理方式请参阅相关 AI 服务提供商的隐私政策。 +

+ +

7. 您的权利

+

您对自己的个人信息享有以下权利:

+
    +
  • 访问权:您可以在个人中心查看您的个人资料和学习数据
  • +
  • 更正权:您可以随时修改您的昵称、头像等个人信息
  • +
  • 删除权:您可以通过应用内的"注销账号"功能删除您的账户和关联数据
  • +
  • 撤回同意:您可以随时停止使用本应用以撤回对数据处理的同意
  • +
+

如需行使上述权利或有其他隐私相关需求,请通过下方联系方式与我们联系。

+ +

8. 儿童隐私

+

+ 本应用面向 13 岁及以上的用户。我们不会故意收集 13 岁以下儿童的个人信息。如果我们发现收集了此类信息,将立即删除。如果您是家长或监护人,发现您的孩子向我们提供了个人信息,请联系我们。 +

+ +

9. 隐私政策的变更

+

+ 我们可能会不时更新本隐私政策。重大变更时,我们会在应用内通知您。继续使用服务即表示您接受更新后的隐私政策。 +

+ +

10. 联系我们

+
+

如果您对本隐私政策有任何问题或疑虑,请通过以下方式联系我们:

+

邮箱noahbreak859@gmail.com

+
+ +

+ 本隐私政策自 2026年2月8日 起生效。 +

+
+ + diff --git a/backend/public/slide-builder.html b/backend/public/slide-builder.html new file mode 100644 index 0000000..3e2922a --- /dev/null +++ b/backend/public/slide-builder.html @@ -0,0 +1,473 @@ + + + + + + 节点内容批量录入工具 - Wild Growth + + + +
+
+

📄 节点内容批量录入工具

+

Wild Growth - 批量创建节点幻灯片内容

+
+ +
+
+ 📋 使用说明: +
    +
  • 1. 填写节点 ID(如:node_01_01)
  • +
  • 2. 填写 JWT Token(从登录接口获取)
  • +
  • 3. 在 JSON 编辑器中输入幻灯片数据
  • +
  • 4. 点击"提交创建"按钮
  • +
  • 5. 查看创建结果
  • +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+

📝 JSON 数据格式示例:

+ { + "slides": [ + { + "id": "node_01_01_slide_01", + "slideType": "text", + "order": 1, + "title": "痛苦的根源", + "paragraphs": [ + "痛苦,往往不是来自外界,而是来自我们内心的认知偏差。", + "当我们认为\"应该\"发生的事情没有发生时,痛苦就产生了。", + "但真相是:世界不会按照我们的期望运转,痛苦是成长的信号。" + ], + "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" + } + ] +} +
+ +
+ + +
+ +
+ + + +
+ +
+
+
+ + + + + + + + diff --git a/backend/public/support.html b/backend/public/support.html new file mode 100644 index 0000000..852f887 --- /dev/null +++ b/backend/public/support.html @@ -0,0 +1,195 @@ + + + + + + 技术支持 - 电子成长 + + + +
+

技术支持

+

电子成长 - AI 驱动的知识学习应用

+ +

关于我们

+

+ 电子成长是一款 AI 驱动的知识学习应用。输入任何你想学的主题,AI 自动生成交互式学习卡片,帮助你轻松掌握新知识。同时提供精选课程库,涵盖求职面试、自我提升、读书解读等热门话题。 +

+ +
+

📧 联系我们

+

如果您在使用过程中遇到任何问题,或有任何建议和反馈,欢迎通过以下方式联系我们:

+

+ 技术支持邮箱:
+ +

+

+ 我们会在收到您的邮件后尽快回复,通常在 1-2 个工作日内给您答复。 +

+
+ +

常见问题

+ +
+
Q: 不登录可以使用吗?
+
+ A: 可以。电子成长支持游客模式,您无需注册即可浏览发现页的精选课程。登录后可以使用 AI 创建课程、保存学习进度、创建笔记等完整功能。 +
+
+ +
+
Q: 如何注册账号?
+
+ A: 您可以使用手机号验证码登录,或使用 Apple ID 登录。首次登录时系统会自动为您创建账号。 +
+
+ +
+
Q: 忘记密码怎么办?
+
+ A: 电子成长采用手机号验证码登录,无需设置密码。如果您使用 Apple ID 登录,请使用 Apple 提供的密码恢复功能。 +
+
+ +
+
Q: AI 创建课程是怎么回事?
+
+ A: 输入任何你想学的主题(如"如何提升写作能力"),选择一位讲解老师,AI 会自动为你生成一套结构化的交互式学习卡片。你也可以粘贴一段文字或文档,AI 会将其转化为易于学习的内容。 +
+
+ +
+
Q: 学习进度会同步吗?
+
+ A: 是的,登录后您的学习进度会保存在云端,可以在不同设备间同步。 +
+
+ +
+
Q: 如何注销账号?
+
+ A: 您可以在 App 内"我的"页面,点击右上角"账户",选择"注销账号"。请注意,注销账号是不可逆的操作,将永久删除您的所有数据。 +
+
+ +
+
Q: App 是否收费?
+
+ A: 电子成长目前完全免费使用,所有功能均可免费体验。 +
+
+ +
+
Q: 如何反馈问题或建议?
+
+ A: 请发送邮件至 noahbreak859@gmail.com,我们会认真对待每一条反馈,持续改进产品体验。 +
+
+ +

隐私和安全

+

+ 我们非常重视您的隐私和数据安全。有关我们如何收集、使用和保护您的个人信息,请参阅我们的 + 隐私政策。 +

+ +

+ 有关使用条款和条件,请参阅我们的 + 用户服务协议。 +

+ +
+

+ © 2026 电子成长. 保留所有权利. +

+
+
+ + diff --git a/backend/public/user-agreement.html b/backend/public/user-agreement.html new file mode 100644 index 0000000..ba7017b --- /dev/null +++ b/backend/public/user-agreement.html @@ -0,0 +1,227 @@ + + + + + + 用户服务协议 - 电子成长 + + + +
+

用户服务协议

+

最后更新时间:2026年2月8日

+ +
+

重要提示:请仔细阅读本协议。使用「电子成长」应用即表示您同意接受本协议的所有条款。如果您不同意本协议的任何内容,请不要使用本应用。

+
+ +

1. 协议的接受

+

+ 欢迎使用「电子成长」(以下简称"本应用")。本用户服务协议(以下简称"本协议")是您与本应用运营方之间关于使用本应用的法律协议。通过下载、安装、访问或使用本应用,您表示同意受本协议约束。 +

+ +

2. 服务描述

+

+ 电子成长是一款 AI 驱动的知识学习应用,致力于帮助用户将任意主题或文本转化为可学习的卡片式课程。我们提供以下核心功能: +

+
    +
  • 发现:浏览编辑推荐的公开课程与运营位内容
  • +
  • AI 创建课程:输入任意主题,选择讲解老师风格(如小红学姐、林老师、丁老师),由 AI 自动生成结构化卡片课程
  • +
  • 文本解析:粘贴或上传文档,AI 将其解析为可学习的课程
  • +
  • 续旧课:基于已有课程内容继续扩展
  • +
  • 课程学习:竖屏卡片式学习体验,按小节学习并记录进度
  • +
  • 背包(内容管理):管理已加入和已创建的课程
  • +
  • 笔记:在学习过程中创建笔记,支持笔记本分类管理
  • +
  • 个人中心:头像、昵称、学习统计等
  • +
+

+ 您可以以游客模式浏览发现页的公开课程;登录后可使用完整功能(AI 创建课程、笔记、学习进度同步等)。 +

+ +

3. 账户注册和使用

+

3.1 账户与登录

+
    +
  • 您可通过手机号验证码Apple 登录注册和登录账户
  • +
  • 未登录时,您可以游客模式浏览公开课程;登录后可使用 AI 创建课程、笔记、学习进度同步等完整功能
  • +
  • 您应提供真实、准确的信息,并保护账户安全,对账户下的所有活动负责
  • +
  • 您不得与他人共享账户或允许他人使用您的账户
  • +
+ +

3.2 使用规则

+

您同意不会:

+
    +
  • 以任何非法方式使用本应用
  • +
  • 干扰或破坏本应用或相关服务器
  • +
  • 尝试未经授权访问本应用或相关系统
  • +
  • 复制、修改、分发、出售或租赁本应用或其任何部分
  • +
  • 使用自动化工具(如机器人、爬虫)访问本应用
  • +
  • 传播病毒、恶意代码或其他有害内容
  • +
  • 利用 AI 创建功能生成违反法律法规或公序良俗的内容
  • +
+ +

4. AI 生成内容

+

4.1 内容生成

+

+ 本应用使用人工智能技术根据您提供的主题或文本生成课程内容。AI 生成的内容仅供学习参考,不保证其准确性、完整性或时效性。您应对 AI 生成内容的使用做出独立判断。 +

+ +

4.2 并发限制

+

+ 为保障服务质量,每位用户同时进行中的 AI 课程生成任务数量有限。请在当前任务完成后再创建新的生成任务。 +

+ +

5. 知识产权

+

+ 本应用及其所有原创内容(包括但不限于界面设计、图标、代码、文案)的知识产权归本应用运营方所有。AI 根据您的输入生成的课程内容,您可在本应用内自由学习和使用。未经我们明确书面许可,您不得将应用内容用于商业用途。 +

+ +

6. 用户生成内容

+

+ 您在本应用中提交的内容(包括但不限于 AI 创建课程时输入的主题或文本、学习笔记),您授予我们非独占、免版税的许可,以使用这些内容用于提供和改进服务。我们不会将您的个人输入内容用于与提供服务无关的营销活动或对外公开共享。 +

+ +

7. 免责声明

+

+ 本应用按"现状"提供。我们不保证: +

+
    +
  • 应用将始终可用、无错误或安全
  • +
  • 应用将满足您的所有需求
  • +
  • AI 生成的课程内容准确、完整或适合您的具体学习场景
  • +
+

+ 本应用提供的内容仅供学习参考,不构成专业建议。对于因使用或无法使用本应用而产生的任何损害,我们不承担责任。 +

+ +

8. 责任限制

+

+ 在法律允许的最大范围内,本应用运营方不对因使用或无法使用本应用而产生的任何间接、偶然、特殊、后果性或惩罚性损害承担责任。 +

+ +

9. 服务变更和终止

+

9.1 服务变更

+

+ 我们保留随时修改、暂停或终止本应用或其任何部分的权利,无需提前通知。 +

+ +

9.2 账户终止

+

+ 如果您违反本协议,我们保留立即终止或暂停您账户的权利。您也可以随时通过应用内的"注销账号"功能或联系我们来删除您的账户。 +

+ +

10. 隐私

+

+ 我们非常重视您的隐私。有关我们如何收集、使用和保护您的个人信息,请参阅我们的隐私政策。 +

+ +

11. 协议修改

+

+ 我们可能会不时更新本协议。重大变更时,我们会在应用内通知您。继续使用服务即表示您接受更新后的协议。如果您不同意修改后的协议,请停止使用本应用。 +

+ +

12. 适用法律

+

+ 本协议受中华人民共和国法律管辖。因本协议引起的任何争议,双方应友好协商解决;协商不成的,应提交有管辖权的人民法院解决。 +

+ +

13. 其他条款

+
    +
  • 如果本协议的任何条款被认定为无效或不可执行,其余条款仍然有效
  • +
  • 本协议构成您与我们之间关于使用本应用的完整协议
  • +
  • 我们未行使本协议中的任何权利不构成对该权利的放弃
  • +
+ +

14. 联系我们

+
+

如果您对本协议有任何问题,请通过以下方式联系我们:

+

邮箱noahbreak859@gmail.com

+
+ +

+ 本协议自 2026年2月8日 起生效。 +

+
+ + diff --git a/backend/public/xhs-cover-templates.html b/backend/public/xhs-cover-templates.html new file mode 100644 index 0000000..1f45a18 --- /dev/null +++ b/backend/public/xhs-cover-templates.html @@ -0,0 +1,107 @@ + + + + + + 小红书爆款封面模板 · Playground + + + +

小红书爆款封面模板

+

共 21 套模板(可直接实现 + 部分实现),点击「大图 / 自定义文案」可打开 API 链接并传 ?text=行1|行2 自定义文案

+
+ + + + diff --git a/backend/public/xhs-covers/cute-cartoon.png b/backend/public/xhs-covers/cute-cartoon.png new file mode 100644 index 0000000..c697b5e Binary files /dev/null and b/backend/public/xhs-covers/cute-cartoon.png differ diff --git a/backend/public/xhs-covers/dark-mode.png b/backend/public/xhs-covers/dark-mode.png new file mode 100644 index 0000000..a4907e3 Binary files /dev/null and b/backend/public/xhs-covers/dark-mode.png differ diff --git a/backend/public/xhs-covers/fashion-trendy.png b/backend/public/xhs-covers/fashion-trendy.png new file mode 100644 index 0000000..1f8d694 Binary files /dev/null and b/backend/public/xhs-covers/fashion-trendy.png differ diff --git a/backend/public/xhs-covers/fresh-melon.png b/backend/public/xhs-covers/fresh-melon.png new file mode 100644 index 0000000..92db771 Binary files /dev/null and b/backend/public/xhs-covers/fresh-melon.png differ diff --git a/backend/public/xhs-covers/fresh-oxygen.png b/backend/public/xhs-covers/fresh-oxygen.png new file mode 100644 index 0000000..d361b53 Binary files /dev/null and b/backend/public/xhs-covers/fresh-oxygen.png differ diff --git a/backend/public/xhs-covers/gradient-blue.png b/backend/public/xhs-covers/gradient-blue.png new file mode 100644 index 0000000..a4b3dbc Binary files /dev/null and b/backend/public/xhs-covers/gradient-blue.png differ diff --git a/backend/public/xhs-covers/gradient-soft.png b/backend/public/xhs-covers/gradient-soft.png new file mode 100644 index 0000000..446a9b9 Binary files /dev/null and b/backend/public/xhs-covers/gradient-soft.png differ diff --git a/backend/public/xhs-covers/grid-card.png b/backend/public/xhs-covers/grid-card.png new file mode 100644 index 0000000..379d559 Binary files /dev/null and b/backend/public/xhs-covers/grid-card.png differ diff --git a/backend/public/xhs-covers/keyword-style.png b/backend/public/xhs-covers/keyword-style.png new file mode 100644 index 0000000..75b921c Binary files /dev/null and b/backend/public/xhs-covers/keyword-style.png differ diff --git a/backend/public/xhs-covers/literary-quote.png b/backend/public/xhs-covers/literary-quote.png new file mode 100644 index 0000000..070758d Binary files /dev/null and b/backend/public/xhs-covers/literary-quote.png differ diff --git a/backend/public/xhs-covers/magazine.png b/backend/public/xhs-covers/magazine.png new file mode 100644 index 0000000..966c8ac Binary files /dev/null and b/backend/public/xhs-covers/magazine.png differ diff --git a/backend/public/xhs-covers/memo-note.png b/backend/public/xhs-covers/memo-note.png new file mode 100644 index 0000000..027ad96 Binary files /dev/null and b/backend/public/xhs-covers/memo-note.png differ diff --git a/backend/public/xhs-covers/minimal-white.png b/backend/public/xhs-covers/minimal-white.png new file mode 100644 index 0000000..9995983 Binary files /dev/null and b/backend/public/xhs-covers/minimal-white.png differ diff --git a/backend/public/xhs-covers/pastel-cute.png b/backend/public/xhs-covers/pastel-cute.png new file mode 100644 index 0000000..00a9167 Binary files /dev/null and b/backend/public/xhs-covers/pastel-cute.png differ diff --git a/backend/public/xhs-covers/pixel-note.png b/backend/public/xhs-covers/pixel-note.png new file mode 100644 index 0000000..df8a110 Binary files /dev/null and b/backend/public/xhs-covers/pixel-note.png differ diff --git a/backend/public/xhs-covers/quality-solitude.png b/backend/public/xhs-covers/quality-solitude.png new file mode 100644 index 0000000..86a5190 Binary files /dev/null and b/backend/public/xhs-covers/quality-solitude.png differ diff --git a/backend/public/xhs-covers/quote-card.png b/backend/public/xhs-covers/quote-card.png new file mode 100644 index 0000000..eb27a50 Binary files /dev/null and b/backend/public/xhs-covers/quote-card.png differ diff --git a/backend/public/xhs-covers/quote-pink.png b/backend/public/xhs-covers/quote-pink.png new file mode 100644 index 0000000..e9410dd Binary files /dev/null and b/backend/public/xhs-covers/quote-pink.png differ diff --git a/backend/public/xhs-covers/retro-study.png b/backend/public/xhs-covers/retro-study.png new file mode 100644 index 0000000..0599c21 Binary files /dev/null and b/backend/public/xhs-covers/retro-study.png differ diff --git a/backend/public/xhs-covers/sunset.png b/backend/public/xhs-covers/sunset.png new file mode 100644 index 0000000..581337e Binary files /dev/null and b/backend/public/xhs-covers/sunset.png differ diff --git a/backend/public/xhs-covers/text-note-orange.png b/backend/public/xhs-covers/text-note-orange.png new file mode 100644 index 0000000..8a9ca5a Binary files /dev/null and b/backend/public/xhs-covers/text-note-orange.png differ diff --git a/backend/scripts/TEST_GUIDE.md b/backend/scripts/TEST_GUIDE.md new file mode 100644 index 0000000..d8a1e43 --- /dev/null +++ b/backend/scripts/TEST_GUIDE.md @@ -0,0 +1,64 @@ +# 课程生成功能测试指南 + +## 部署状态 + +✅ **代码已部署到服务器** +- 服务器地址: https://api.muststudy.xin +- PM2 服务: 运行中 +- 修复内容: 大纲生成完成后自动开始生成课程内容 + +## 测试方法 + +### 方法1: 使用测试脚本(推荐) + +1. **获取Token** + - 使用手机号登录获取Token + - 或使用现有的测试账号 + +2. **运行测试脚本** + ```bash + export TEST_TOKEN='your_token_here' + cd backend + bash scripts/test-course-generation-direct.sh + ``` + +### 方法2: 直接调用API + +1. **创建课程** + ```bash + curl -X POST https://api.muststudy.xin/api/ai/content/upload \ + -H 'Authorization: Bearer YOUR_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "content": "测试内容...", + "style": "essence" + }' + ``` + +2. **查询状态** + ```bash + curl -X GET https://api.muststudy.xin/api/my-courses \ + -H 'Authorization: Bearer YOUR_TOKEN' + ``` + +3. **查询日志** + ```bash + curl -X GET "https://api.muststudy.xin/api/ai/prompts/logs?taskId=TASK_ID" \ + -H 'Authorization: Bearer YOUR_TOKEN' + ``` + +## 测试检查点 + +1. ✅ 创建课程后立即返回 courseId 和 taskId +2. ✅ 进度从 0% 开始,逐步增长 +3. ✅ 大纲生成完成后(30%)自动继续生成内容(40%+) +4. ✅ 最终完成(100%) +5. ✅ 所有步骤都记录了日志(可通过 taskId 查询) + +## 问题排查 + +如果进度卡在30%: +- 检查服务器日志: `pm2 logs wildgrowth-api` +- 检查任务状态: `GET /api/ai/content/tasks/:taskId` +- 检查错误信息: 查看 `error_message` 字段 + diff --git a/backend/scripts/backfill-banner-watermark.ts b/backend/scripts/backfill-banner-watermark.ts new file mode 100644 index 0000000..497550e --- /dev/null +++ b/backend/scripts/backfill-banner-watermark.ts @@ -0,0 +1,44 @@ +/** + * 一次性脚本:为「当前已在已启用运营位里的课程」批量设置水印为 globe.americas.fill + * 使用:npx ts-node scripts/backfill-banner-watermark.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const WATERMARK_ICON = 'globe.americas.fill'; + +async function main() { + // 1. 已启用、未删的运营位中的 courseId(去重) + const rows = await prisma.operationalBannerCourse.findMany({ + where: { + banner: { deletedAt: null, isEnabled: true }, + }, + select: { courseId: true }, + distinct: ['courseId'], + }); + const courseIds = rows.map((r) => r.courseId); + + if (courseIds.length === 0) { + console.log('[BackfillBannerWatermark] 当前没有运营位关联的课程,跳过'); + return; + } + + console.log(`[BackfillBannerWatermark] 运营位课程数: ${courseIds.length}, courseIds:`, courseIds); + + // 2. 批量更新 watermark_icon + const result = await prisma.course.updateMany({ + where: { id: { in: courseIds } }, + data: { watermarkIcon: WATERMARK_ICON }, + }); + + console.log(`[BackfillBannerWatermark] 已更新 ${result.count} 门课程的水印为 ${WATERMARK_ICON}`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/backfill-notebooks-cover-production.sh b/backend/scripts/backfill-notebooks-cover-production.sh new file mode 100755 index 0000000..1685568 --- /dev/null +++ b/backend/scripts/backfill-notebooks-cover-production.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# 在生产服务器上执行数据清洗脚本 +SERVER_IP="120.55.112.195" +SERVER_USER="root" +SERVER_PASS="yangyichenYANGYICHENkaifa859" + +echo "🚀 开始在生产服务器上清洗笔记本封面数据..." + +sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no "$SERVER_USER"@"$SERVER_IP" << 'REMOTE_SCRIPT' +cd /var/www/wildgrowth-backend/backend +npx ts-node scripts/backfill-notebooks-cover.ts +REMOTE_SCRIPT + +echo "✅ 数据清洗完成!" diff --git a/backend/scripts/backfill-notebooks-cover.ts b/backend/scripts/backfill-notebooks-cover.ts new file mode 100644 index 0000000..f9af45a --- /dev/null +++ b/backend/scripts/backfill-notebooks-cover.ts @@ -0,0 +1,124 @@ +import { PrismaClient } from '@prisma/client'; +import { logger } from '../src/utils/logger'; + +const prisma = new PrismaClient(); + +/** + * 数据清洗脚本:为所有没有封面的笔记本自动补上相关课程的封面 + */ +async function backfillNotebooksCover() { + try { + console.log('=== 开始清洗笔记本封面数据 ===\n'); + + // 获取所有没有封面的笔记本 + const notebooks = await prisma.notebook.findMany({ + where: { + OR: [ + { coverImage: null }, + { coverImage: '' }, + ], + }, + }); + + console.log(`找到 ${notebooks.length} 个需要补封面的笔记本\n`); + + let successCount = 0; + let failCount = 0; + + for (const notebook of notebooks) { + let coverImage: string | null = null; + + try { + // 方法1:从关联笔记的课程中获取 + const firstNote = await prisma.note.findFirst({ + where: { + notebookId: notebook.id, + courseId: { not: null }, + }, + orderBy: { createdAt: 'asc' }, + include: { + course: { + select: { + coverImage: true, + }, + }, + }, + }); + + if (firstNote?.course?.coverImage) { + coverImage = firstNote.course.coverImage; + console.log(`✅ [${notebook.id}] 从关联笔记的课程获取封面: ${coverImage}`); + } else if (firstNote?.courseId) { + // 备用方案:单独查询课程 + const course = await prisma.course.findUnique({ + where: { id: firstNote.courseId }, + select: { coverImage: true }, + }); + + if (course?.coverImage) { + coverImage = course.coverImage; + console.log(`✅ [${notebook.id}] 从课程 ${firstNote.courseId} 获取封面(备用方案): ${coverImage}`); + } + } + + // 方法2:如果方法1失败,通过课程名称匹配 + if (!coverImage) { + const courseName = notebook.title + .replace(/《/g, '') + .replace(/》/g, '') + .trim(); + + if (courseName) { + // 先尝试精确匹配 + let matchingCourse = await prisma.course.findFirst({ + where: { title: courseName }, + select: { id: true, title: true, coverImage: true }, + }); + + // 如果精确匹配失败,尝试包含匹配 + if (!matchingCourse) { + matchingCourse = await prisma.course.findFirst({ + where: { + title: { + contains: courseName, + }, + }, + select: { id: true, title: true, coverImage: true }, + }); + } + + if (matchingCourse?.coverImage) { + coverImage = matchingCourse.coverImage; + console.log(`✅ [${notebook.id}] 通过课程名称匹配获取封面: ${matchingCourse.title} -> ${coverImage}`); + } + } + } + + // 更新笔记本封面 + if (coverImage) { + await prisma.notebook.update({ + where: { id: notebook.id }, + data: { coverImage }, + }); + successCount++; + } else { + console.log(`⚠️ [${notebook.id}] "${notebook.title}" 无法找到匹配的课程封面`); + failCount++; + } + } catch (error) { + console.error(`❌ [${notebook.id}] 处理失败:`, error); + failCount++; + } + } + + console.log(`\n=== 清洗完成 ===`); + console.log(`成功: ${successCount} 个`); + console.log(`失败: ${failCount} 个`); + } catch (error) { + console.error('脚本执行失败:', error); + } finally { + await prisma.$disconnect(); + } +} + +backfillNotebooksCover(); diff --git a/backend/scripts/backfill-theme-colors.ts b/backend/scripts/backfill-theme-colors.ts new file mode 100644 index 0000000..8f52e26 --- /dev/null +++ b/backend/scripts/backfill-theme-colors.ts @@ -0,0 +1,42 @@ +/** + * 一次性脚本:按新 6 色池(品牌蓝 + 多邻国绿/紫/红/黄/橙)重算并更新所有课程的 theme_color + * 使用:npx ts-node scripts/backfill-theme-colors.ts + */ + +import { PrismaClient } from '@prisma/client'; +import { generateThemeColor } from '../src/utils/courseTheme'; + +const prisma = new PrismaClient(); + +async function main() { + const courses = await prisma.course.findMany({ + select: { id: true }, + where: { deletedAt: null }, + }); + + if (courses.length === 0) { + console.log('[BackfillThemeColors] 没有课程,跳过'); + return; + } + + console.log(`[BackfillThemeColors] 共 ${courses.length} 门课程,开始按 courseId 哈希重算 theme_color...`); + + let updated = 0; + for (const c of courses) { + const themeColor = generateThemeColor(c.id); + await prisma.course.update({ + where: { id: c.id }, + data: { themeColor }, + }); + updated++; + } + + console.log(`[BackfillThemeColors] 已更新 ${updated} 门课程的 theme_color(6 色池:品牌蓝 + 多邻国绿/紫/红/黄/橙)`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/check-course-cover.ts b/backend/scripts/check-course-cover.ts new file mode 100644 index 0000000..9401571 --- /dev/null +++ b/backend/scripts/check-course-cover.ts @@ -0,0 +1,37 @@ +/** + * 查某门课的封面/主题色/水印(按标题模糊匹配) + * 使用: npx ts-node scripts/check-course-cover.ts [关键词] + * 例: npx ts-node scripts/check-course-cover.ts 产品思维 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const keyword = process.argv[2] || '产品思维'; + const courses = await prisma.course.findMany({ + where: { + deletedAt: null, + title: { contains: keyword, mode: 'insensitive' }, + }, + select: { + id: true, + title: true, + coverImage: true, + }, + }); + + console.log(`\n课程标题含「${keyword}」的记录数: ${courses.length}\n`); + for (const c of courses) { + console.log('---'); + console.log('id:', c.id); + console.log('title:', c.title); + console.log('cover_image:', c.coverImage ?? '(null)'); + console.log(''); + } +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/check-latest-book-task.ts b/backend/scripts/check-latest-book-task.ts new file mode 100644 index 0000000..9de8730 --- /dev/null +++ b/backend/scripts/check-latest-book-task.ts @@ -0,0 +1,53 @@ +/** + * 查询最新书籍解析任务(course_generation_tasks) + * 用于诊断超时、失败:npx ts-node scripts/check-latest-book-task.ts + * 需在 backend 目录执行,或设置 DATABASE_URL + */ + +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const rows = await prisma.courseGenerationTask.findMany({ + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + courseId: true, + userId: true, + status: true, + progress: true, + errorMessage: true, + currentStep: true, + sourceText: true, + createdAt: true, + updatedAt: true, + course: { select: { title: true, totalNodes: true } }, + }, + }); + + console.log('\n========== 最新 5 条书籍解析任务 ==========\n'); + for (let i = 0; i < rows.length; i++) { + const t = rows[i]; + const len = typeof t.sourceText === 'string' ? t.sourceText.length : 0; + console.log(`--- 任务 ${i + 1} ---`); + console.log(' id: ', t.id); + console.log(' courseId: ', t.courseId); + console.log(' course.title:', (t.course as any)?.title ?? '-'); + console.log(' status: ', t.status); + console.log(' progress: ', t.progress); + console.log(' currentStep:', t.currentStep ?? '-'); + console.log(' errorMessage:', t.errorMessage ?? '-'); + console.log(' sourceText 长度:', len); + console.log(' createdAt: ', t.createdAt); + console.log(' updatedAt: ', t.updatedAt); + console.log(''); + } + console.log('==========================================\n'); +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/check-latest-failure.ts b/backend/scripts/check-latest-failure.ts new file mode 100644 index 0000000..2ad7d02 --- /dev/null +++ b/backend/scripts/check-latest-failure.ts @@ -0,0 +1,72 @@ +/** + * 检查最新失败任务的详细信息 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function checkLatestFailure() { + try { + // 查找最新的失败任务 + const latestFailed = await prisma.aIContentTask.findFirst({ + where: { + status: 'failed', + errorMessage: { + contains: 'response_format', + }, + }, + orderBy: { + createdAt: 'desc', + }, + include: { + course: { + select: { + id: true, + title: true, + generationStatus: true, + generationProgress: true, + }, + }, + }, + }); + + if (!latestFailed) { + console.log('没有找到 response_format 相关的失败任务'); + return; + } + + console.log('\n========== 最新失败任务详情 ==========\n'); + console.log('任务ID:', latestFailed.id); + console.log('状态:', latestFailed.status); + console.log('错误信息:', latestFailed.errorMessage); + console.log('生成风格:', latestFailed.generationStyle); + console.log('创建时间:', latestFailed.createdAt); + console.log('更新时间:', latestFailed.updatedAt); + console.log('\n关联课程:'); + if (latestFailed.course) { + console.log(' 课程ID:', latestFailed.course.id); + console.log(' 课程标题:', latestFailed.course.title); + console.log(' 生成状态:', latestFailed.course.generationStatus); + console.log(' 生成进度:', latestFailed.course.generationProgress); + } else { + console.log(' 无关联课程'); + } + + // 检查是否有 SelectStyle 调用记录 + console.log('\n检查是否有模式选择记录...'); + const hasStyle = latestFailed.generationStyle !== null; + console.log('是否已选择模式:', hasStyle); + if (hasStyle) { + console.log('选择的模式:', latestFailed.generationStyle); + } + + console.log('\n========== 检查完成 ==========\n'); + } catch (error) { + console.error('检查失败:', error); + } finally { + await prisma.$disconnect(); + } +} + +checkLatestFailure(); diff --git a/backend/scripts/check-notebooks-cover.ts b/backend/scripts/check-notebooks-cover.ts new file mode 100644 index 0000000..d5c21e5 --- /dev/null +++ b/backend/scripts/check-notebooks-cover.ts @@ -0,0 +1,29 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function checkNotebooksCover() { + try { + const userId = 'def0ffdc-d078-490c-b713-6462d1027a77'; + + console.log('=== 检查用户笔记本封面 ==='); + const notebooks = await prisma.notebook.findMany({ + where: { userId }, + }); + + console.log(`找到 ${notebooks.length} 个笔记本\n`); + + for (const notebook of notebooks) { + console.log(`ID: ${notebook.id}`); + console.log(`标题: ${notebook.title}`); + console.log(`封面: ${notebook.coverImage || 'null'}`); + console.log('---'); + } + } catch (error) { + console.error('错误:', error); + } finally { + await prisma.$disconnect(); + } +} + +checkNotebooksCover(); diff --git a/backend/scripts/check-stuck-tasks.ts b/backend/scripts/check-stuck-tasks.ts new file mode 100644 index 0000000..511c252 --- /dev/null +++ b/backend/scripts/check-stuck-tasks.ts @@ -0,0 +1,102 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function checkStuckTasks() { + try { + // 查找最近创建的任务 + const recentTasks = await prisma.aIContentTask.findMany({ + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + status: true, + generationStyle: true, + createdAt: true, + courseId: true, + suggestedTitle: true, + sourceText: true, + outline: true, + }, + }); + + console.log('\n=== 最近5个任务 ==='); + recentTasks.forEach((task, index) => { + console.log(`\n任务 ${index + 1}:`); + console.log(` ID: ${task.id}`); + console.log(` 状态: ${task.status}`); + console.log(` 风格: ${task.generationStyle || '未选择'}`); + console.log(` 创建时间: ${task.createdAt}`); + console.log(` 课程ID: ${task.courseId || '无'}`); + console.log(` 标题: ${task.suggestedTitle || '未生成'}`); + console.log(` 文本长度: ${task.sourceText?.length || 0} 字符`); + console.log(` 是否有大纲: ${task.outline ? '是' : '否'}`); + }); + + // 查找卡住的任务(pending 或 analyzing 状态超过1分钟) + const oneMinuteAgo = new Date(Date.now() - 60 * 1000); + const stuckTasks = await prisma.aIContentTask.findMany({ + where: { + status: { + in: ['pending', 'analyzing'], + }, + createdAt: { + lt: oneMinuteAgo, + }, + }, + select: { + id: true, + status: true, + generationStyle: true, + createdAt: true, + courseId: true, + }, + }); + + if (stuckTasks.length > 0) { + console.log('\n=== 卡住的任务(超过1分钟未更新)==='); + stuckTasks.forEach((task) => { + console.log(`\n任务 ID: ${task.id}`); + console.log(` 状态: ${task.status}`); + console.log(` 风格: ${task.generationStyle || '未选择'}`); + console.log(` 创建时间: ${task.createdAt}`); + console.log(` 课程ID: ${task.courseId || '无'}`); + }); + } else { + console.log('\n✅ 没有发现卡住的任务'); + } + + // 检查课程进度 + if (recentTasks.length > 0) { + const courseIds = recentTasks + .map((t) => t.courseId) + .filter((id): id is string => id !== null); + + if (courseIds.length > 0) { + const courses = await prisma.course.findMany({ + where: { id: { in: courseIds } }, + select: { + id: true, + title: true, + generationProgress: true, + generationStatus: true, + }, + }); + + console.log('\n=== 关联的课程进度 ==='); + courses.forEach((course) => { + console.log(`\n课程 ID: ${course.id}`); + console.log(` 标题: ${course.title}`); + console.log(` 生成状态: ${course.generationStatus || '无'}`); + console.log(` 生成进度: ${(course.generationProgress || 0) * 100}%`); + }); + } + } + } catch (error: any) { + console.error('检查失败:', error.message); + } finally { + await prisma.$disconnect(); + } +} + +checkStuckTasks(); diff --git a/backend/scripts/check-task-status.ts b/backend/scripts/check-task-status.ts new file mode 100644 index 0000000..c0a5f72 --- /dev/null +++ b/backend/scripts/check-task-status.ts @@ -0,0 +1,122 @@ +/** + * 检查任务状态脚本 + * 用于诊断卡住的任务:查看任务状态、进度、创建时间等 + * + * 使用方法: + * npx ts-node scripts/check-task-status.ts [taskId] + * 如果不提供 taskId,则显示所有进行中的任务 + */ + +import { PrismaClient } from '@prisma/client'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const prisma = new PrismaClient(); + +async function checkTaskStatus(taskId?: string) { + try { + if (taskId) { + // 查询指定任务 + const task = await prisma.courseGenerationTask.findUnique({ + where: { id: taskId }, + include: { + course: { + select: { + id: true, + title: true, + status: true, + }, + }, + user: { + select: { + id: true, + phone: true, + nickname: true, + }, + }, + }, + }); + + if (!task) { + console.log(`❌ 任务不存在: ${taskId}`); + return; + } + + console.log('\n=== 任务详情 ==='); + console.log(`任务ID: ${task.id}`); + console.log(`课程ID: ${task.courseId}`); + console.log(`课程标题: ${task.course.title}`); + console.log(`用户ID: ${task.userId}`); + console.log(`用户: ${task.user.nickname || task.user.phone || 'N/A'}`); + console.log(`状态: ${task.status}`); + console.log(`进度: ${task.progress}%`); + console.log(`当前步骤: ${task.currentStep || 'N/A'}`); + console.log(`错误信息: ${task.errorMessage || 'N/A'}`); + console.log(`来源类型: ${task.sourceType || 'N/A'}`); + console.log(`导师类型: ${task.persona || 'N/A'}`); + console.log(`创建时间: ${task.createdAt}`); + console.log(`更新时间: ${task.updatedAt}`); + console.log(`运行时长: ${Math.floor((Date.now() - task.createdAt.getTime()) / 1000 / 60)} 分钟`); + + if (task.status !== 'completed' && task.status !== 'failed') { + const minutesSinceUpdate = Math.floor((Date.now() - task.updatedAt.getTime()) / 1000 / 60); + if (minutesSinceUpdate > 5) { + console.log(`⚠️ 警告: 任务已 ${minutesSinceUpdate} 分钟未更新,可能已卡住`); + } + } + } else { + // 查询所有进行中的任务 + const tasks = await prisma.courseGenerationTask.findMany({ + where: { + status: { + notIn: ['completed', 'failed'], + }, + }, + include: { + course: { + select: { + id: true, + title: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + if (tasks.length === 0) { + console.log('✅ 没有进行中的任务'); + return; + } + + console.log(`\n=== 进行中的任务 (共 ${tasks.length} 个) ===\n`); + + for (const task of tasks) { + const minutesSinceUpdate = Math.floor((Date.now() - task.updatedAt.getTime()) / 1000 / 60); + const minutesSinceCreate = Math.floor((Date.now() - task.createdAt.getTime()) / 1000 / 60); + + console.log(`任务ID: ${task.id}`); + console.log(`课程: ${task.course.title} (${task.courseId})`); + console.log(`状态: ${task.status} | 进度: ${task.progress}%`); + console.log(`创建时间: ${task.createdAt} (${minutesSinceCreate} 分钟前)`); + console.log(`更新时间: ${task.updatedAt} (${minutesSinceUpdate} 分钟前)`); + + if (minutesSinceUpdate > 5) { + console.log(`⚠️ 警告: 已 ${minutesSinceUpdate} 分钟未更新,可能已卡住`); + } + console.log('---'); + } + } + } catch (error: any) { + console.error('❌ 查询失败:', error.message); + } finally { + await prisma.$disconnect(); + } +} + +// 从命令行参数获取 taskId +const taskId = process.argv[2]; + +checkTaskStatus(taskId); diff --git a/backend/scripts/check-watermark.js b/backend/scripts/check-watermark.js new file mode 100644 index 0000000..d9b435c --- /dev/null +++ b/backend/scripts/check-watermark.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +require('dotenv').config({ path: require('path').join(__dirname, '../.env') }); +const { PrismaClient } = require('@prisma/client'); +const p = new PrismaClient(); + +async function main() { + const c = await p.course.findFirst({ + where: { title: { contains: '数据分析' } }, + orderBy: { createdAt: 'desc' }, + select: { id: true, title: true, watermarkIcon: true, createdAt: true, createdAsDraft: true }, + }); + console.log(JSON.stringify(c, null, 2)); +} + +main() + .then(() => p.$disconnect()) + .catch((e) => { console.error(e); process.exit(1); }); diff --git a/backend/scripts/cleanup-stuck-courses.ts b/backend/scripts/cleanup-stuck-courses.ts new file mode 100644 index 0000000..91c66a8 --- /dev/null +++ b/backend/scripts/cleanup-stuck-courses.ts @@ -0,0 +1,111 @@ +/** + * 清理卡在30%的课程数据 + * 使用方法:ts-node backend/scripts/cleanup-stuck-courses.ts + */ + +import prisma from '../src/utils/prisma'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +// 加载环境变量 +dotenv.config({ path: path.join(__dirname, '../.env') }); + +async function cleanupStuckCourses() { + try { + console.log('🔍 查找卡在30%的课程...\n'); + + // 查找状态为generating且progress为0.3的课程 + const stuckCourses = await prisma.course.findMany({ + where: { + generationStatus: 'generating', + generationProgress: 0.3, + }, + include: { + aiContentTask: { + select: { + id: true, + status: true, + suggestedTitle: true, + }, + }, + }, + }); + + console.log(`找到 ${stuckCourses.length} 个卡住的课程:\n`); + + if (stuckCourses.length === 0) { + console.log('✅ 没有需要清理的课程'); + await prisma.$disconnect(); + return; + } + + // 显示课程信息 + stuckCourses.forEach((course, index) => { + console.log(`${index + 1}. ${course.title}`); + console.log(` Course ID: ${course.id}`); + console.log(` Task ID: ${course.aiContentTask?.id || 'N/A'}`); + console.log(` 创建时间: ${course.createdAt}`); + console.log(''); + }); + + // 确认删除 + console.log('⚠️ 准备删除以上课程及其关联数据...\n'); + + let deletedCount = 0; + let errorCount = 0; + + for (const course of stuckCourses) { + try { + // 1. 删除 UserCourse 关联 + await prisma.userCourse.deleteMany({ + where: { + courseId: course.id, + }, + }); + + // 2. 删除 Course 记录 + await prisma.course.delete({ + where: { + id: course.id, + }, + }); + + // 3. 删除 AIContentTask(如果存在) + if (course.aiContentTaskId) { + await prisma.aIPromptLog.deleteMany({ + where: { + taskId: course.aiContentTaskId, + }, + }); + + await prisma.aIContentTask.delete({ + where: { + id: course.aiContentTaskId, + }, + }); + } + + deletedCount++; + console.log(`✅ 已删除: ${course.title} (${course.id})`); + } catch (error: any) { + errorCount++; + console.error(`❌ 删除失败: ${course.title} (${course.id}) - ${error.message}`); + } + } + + console.log('\n📊 清理结果:'); + console.log(` ✅ 成功删除: ${deletedCount} 个课程`); + if (errorCount > 0) { + console.log(` ❌ 删除失败: ${errorCount} 个课程`); + } + + await prisma.$disconnect(); + } catch (error: any) { + console.error('❌ 清理过程出错:', error); + await prisma.$disconnect(); + process.exit(1); + } +} + +// 运行清理 +cleanupStuckCourses(); diff --git a/backend/scripts/create-course-generation-task-table.ts b/backend/scripts/create-course-generation-task-table.ts new file mode 100644 index 0000000..d50863f --- /dev/null +++ b/backend/scripts/create-course-generation-task-table.ts @@ -0,0 +1,94 @@ +/** + * 直接创建 course_generation_tasks 表的脚本 + * 用于绕过 Prisma 迁移问题 + */ + +import { PrismaClient } from '@prisma/client'; +import prisma from '../src/utils/prisma'; + +async function createTable() { + try { + console.log('开始创建 course_generation_tasks 表...'); + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "course_generation_tasks" ( + "id" TEXT NOT NULL, + "course_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "source_text" TEXT NOT NULL, + "mode" TEXT, + "model_provider" TEXT NOT NULL DEFAULT 'doubao', + "status" TEXT NOT NULL DEFAULT 'pending', + "progress" INTEGER NOT NULL DEFAULT 0, + "error_message" TEXT, + "outline" JSONB, + "current_step" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "course_generation_tasks_pkey" PRIMARY KEY ("id") + ); + `); + + console.log('✅ 表创建成功'); + + // 创建索引 + await prisma.$executeRawUnsafe(` + CREATE INDEX IF NOT EXISTS "course_generation_tasks_user_id_idx" + ON "course_generation_tasks"("user_id"); + `); + + await prisma.$executeRawUnsafe(` + CREATE INDEX IF NOT EXISTS "course_generation_tasks_status_idx" + ON "course_generation_tasks"("status"); + `); + + await prisma.$executeRawUnsafe(` + CREATE UNIQUE INDEX IF NOT EXISTS "course_generation_tasks_course_id_key" + ON "course_generation_tasks"("course_id"); + `); + + console.log('✅ 索引创建成功'); + + // 创建外键 + await prisma.$executeRawUnsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'course_generation_tasks_course_id_fkey' + ) THEN + ALTER TABLE "course_generation_tasks" + ADD CONSTRAINT "course_generation_tasks_course_id_fkey" + FOREIGN KEY ("course_id") REFERENCES "courses"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + END $$; + `); + + await prisma.$executeRawUnsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'course_generation_tasks_user_id_fkey' + ) THEN + ALTER TABLE "course_generation_tasks" + ADD CONSTRAINT "course_generation_tasks_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + END $$; + `); + + console.log('✅ 外键创建成功'); + console.log('✅ 所有操作完成!'); + } catch (error: any) { + console.error('❌ 创建表失败:', error.message); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +createTable(); diff --git a/backend/scripts/createSystemNotes.ts b/backend/scripts/createSystemNotes.ts new file mode 100644 index 0000000..4315cc0 --- /dev/null +++ b/backend/scripts/createSystemNotes.ts @@ -0,0 +1,220 @@ +/** + * 创建系统笔记脚本 + * 用于在"高效沟通的艺术"课程的前两节插入系统笔记数据 + */ + +import prisma from '../src/utils/prisma'; +import { SYSTEM_USER_ID } from '../src/constants'; +import { logger } from '../src/utils/logger'; + +/** + * 创建系统用户(如果不存在) + */ +async function ensureSystemUser() { + const existingUser = await prisma.user.findUnique({ + where: { id: SYSTEM_USER_ID }, + }); + + if (existingUser) { + logger.info('系统用户已存在'); + return existingUser; + } + + const systemUser = await prisma.user.create({ + data: { + id: SYSTEM_USER_ID, + nickname: '系统', + agreementAccepted: true, + }, + }); + + logger.info('系统用户创建成功'); + return systemUser; +} + +/** + * 查找"高效沟通的艺术"课程 + */ +async function findCourse() { + // 直接使用已知的课程ID和节点ID + const courseId = 'course_vertical_001'; + const node1Id = 'node_vertical_001_01'; + const node2Id = 'node_vertical_001_02'; + + const course = await prisma.course.findUnique({ + where: { id: courseId }, + include: { + nodes: { + where: { + id: { in: [node1Id, node2Id] }, + }, + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!course) { + throw new Error(`未找到课程: ${courseId}`); + } + + if (course.nodes.length < 2) { + throw new Error(`课程只有 ${course.nodes.length} 个节点,需要至少2个节点`); + } + + const node1 = course.nodes.find(n => n.id === 'node_vertical_001_01'); + const node2 = course.nodes.find(n => n.id === 'node_vertical_001_02'); + + if (!node1 || !node2) { + throw new Error('未找到前两节节点'); + } + + logger.info(`找到课程: ${course.title},前两节: ${node1.title}, ${node2.title}`); + return { course, node1, node2 }; +} + +/** + * 创建系统笔记 + */ +async function createSystemNote(data: { + nodeId: string; + courseId: string; + startIndex: number; + length: number; + type: 'highlight' | 'thought'; + content?: string; + quotedText: string; +}) { + // 检查是否已存在相同的系统笔记(避免重复创建) + const existing = await prisma.note.findFirst({ + where: { + userId: SYSTEM_USER_ID, + nodeId: data.nodeId, + startIndex: data.startIndex, + length: data.length, + type: data.type, + }, + }); + + if (existing) { + logger.info(`系统笔记已存在: ${data.quotedText.substring(0, 20)}...`); + return existing; + } + + const note = await prisma.note.create({ + data: { + userId: SYSTEM_USER_ID, + courseId: data.courseId, + nodeId: data.nodeId, + startIndex: data.startIndex, + length: data.length, + type: data.type, + content: data.content || '', // content 字段是必填的,不能为 null + quotedText: data.quotedText, + }, + }); + + logger.info(`系统笔记创建成功: ${data.quotedText.substring(0, 20)}...`); + return note; +} + +/** + * 主函数 + */ +async function main() { + try { + logger.info('开始创建系统笔记...'); + + // 1. 确保系统用户存在 + await ensureSystemUser(); + + // 2. 查找课程 + const { course, node1, node2 } = await findCourse(); + + // 3. 为第一节创建系统笔记 + logger.info('为第一节创建系统笔记...'); + + // 系统笔记1:关于倾听的重要性(对应HTML中的"倾听才是沟通的核心") + await createSystemNote({ + nodeId: node1.id, + courseId: course.id, + startIndex: 0, // 实际位置需要根据解析后的纯文本计算,这里先用0 + length: 10, + type: 'thought', + content: '倾听是沟通的基础,只有真正听懂对方,才能做出有效回应。', + quotedText: '倾听才是沟通的核心', + }); + + // 系统笔记2:关于倾听的三个层次(对应"第一层:听到") + await createSystemNote({ + nodeId: node1.id, + courseId: course.id, + startIndex: 0, + length: 15, + type: 'highlight', + quotedText: '第一层:听到 - 你听到了对方的声音,但可能没有理解。', + }); + + // 系统笔记3:关于如何提升倾听能力(对应"保持专注,避免分心") + await createSystemNote({ + nodeId: node1.id, + courseId: course.id, + startIndex: 0, + length: 8, + type: 'thought', + content: '保持专注,避免分心,是提升倾听能力的第一步。专注能让你捕捉到对方话语中的细微情绪和真实意图。', + quotedText: '保持专注,避免分心', + }); + + // 4. 为第二节创建系统笔记 + logger.info('为第二节创建系统笔记...'); + + // 系统笔记4:关于金字塔原理(对应"金字塔原理") + await createSystemNote({ + nodeId: node2.id, + courseId: course.id, + startIndex: 0, + length: 5, + type: 'thought', + content: '金字塔原理是结构化表达的核心方法:先结论,后原因,再案例。这样能让你的表达更有逻辑性和说服力。', + quotedText: '金字塔原理', + }); + + // 系统笔记5:关于结论先行(对应"结论先行") + await createSystemNote({ + nodeId: node2.id, + courseId: course.id, + startIndex: 0, + length: 4, + type: 'highlight', + quotedText: '结论先行 - 先说你的核心观点', + }); + + // 系统笔记6:关于语言的力量(对应"用肯定的语言替代模糊的表达") + await createSystemNote({ + nodeId: node2.id, + courseId: course.id, + startIndex: 0, + length: 12, + type: 'thought', + content: '用肯定的语言替代模糊的表达,会让你的观点更可信。避免使用"可能"、"也许"等不确定的词汇。', + quotedText: '用肯定的语言替代模糊的表达,会让你的观点更可信', + }); + + logger.info('系统笔记创建完成!'); + } catch (error) { + logger.error('创建系统笔记失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 运行脚本 +main() + .then(() => { + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/backend/scripts/delete-ai-tables.ts b/backend/scripts/delete-ai-tables.ts new file mode 100644 index 0000000..5d73808 --- /dev/null +++ b/backend/scripts/delete-ai-tables.ts @@ -0,0 +1,63 @@ +/** + * 删除 AI 相关数据库表 + * 直接执行 SQL,不通过 Prisma 迁移 + */ + +import { PrismaClient } from '@prisma/client'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const prisma = new PrismaClient(); + +async function deleteAITables() { + try { + console.log('🗑️ 开始删除 AI 相关表...'); + + // 删除表(按依赖顺序) + await prisma.$executeRawUnsafe(` + DROP TABLE IF EXISTS "document_chunks" CASCADE; + `); + console.log('✅ 已删除 document_chunks 表'); + + await prisma.$executeRawUnsafe(` + DROP TABLE IF EXISTS "ai_prompt_logs" CASCADE; + `); + console.log('✅ 已删除 ai_prompt_logs 表'); + + await prisma.$executeRawUnsafe(` + DROP TABLE IF EXISTS "ai_prompt_configs" CASCADE; + `); + console.log('✅ 已删除 ai_prompt_configs 表'); + + await prisma.$executeRawUnsafe(` + DROP TABLE IF EXISTS "ai_content_tasks" CASCADE; + `); + console.log('✅ 已删除 ai_content_tasks 表'); + + // 删除 Course 表中的 AI 相关字段 + await prisma.$executeRawUnsafe(` + ALTER TABLE "courses" DROP COLUMN IF EXISTS "generation_status"; + `); + console.log('✅ 已删除 courses.generation_status 字段'); + + await prisma.$executeRawUnsafe(` + ALTER TABLE "courses" DROP COLUMN IF EXISTS "generation_progress"; + `); + console.log('✅ 已删除 courses.generation_progress 字段'); + + await prisma.$executeRawUnsafe(` + ALTER TABLE "courses" DROP COLUMN IF EXISTS "ai_content_task_id"; + `); + console.log('✅ 已删除 courses.ai_content_task_id 字段'); + + console.log('✅ 所有 AI 相关表已删除!'); + } catch (error) { + console.error('❌ 删除失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +deleteAITables(); diff --git a/backend/scripts/delete-all-notebooks.js b/backend/scripts/delete-all-notebooks.js new file mode 100644 index 0000000..2d7e978 --- /dev/null +++ b/backend/scripts/delete-all-notebooks.js @@ -0,0 +1,23 @@ +// 临时脚本:删除所有笔记本数据 +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function deleteAllNotebooks() { + try { + console.log('🗑️ 开始删除所有笔记本数据...'); + + // 删除所有笔记本(级联删除笔记,由 Prisma 的 onDelete: Cascade 处理) + const deleteResult = await prisma.notebook.deleteMany({}); + + console.log(`✅ 已删除 ${deleteResult.count} 个笔记本`); + + console.log('✅ 删除完成!'); + } catch (error) { + console.error('❌ 删除失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +deleteAllNotebooks(); diff --git a/backend/scripts/delete-all-notes.js b/backend/scripts/delete-all-notes.js new file mode 100644 index 0000000..b5a7806 --- /dev/null +++ b/backend/scripts/delete-all-notes.js @@ -0,0 +1,27 @@ +// 临时脚本:删除所有笔记数据 +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function deleteAllNotes() { + try { + console.log('🗑️ 开始删除所有笔记数据...'); + + // 删除所有笔记 + const deleteResult = await prisma.note.deleteMany({}); + + console.log(`✅ 已删除 ${deleteResult.count} 条笔记记录`); + + // 可选:删除所有笔记本(如果用户需要) + // const deleteNotebooksResult = await prisma.notebook.deleteMany({}); + // console.log(`✅ 已删除 ${deleteNotebooksResult.count} 个笔记本`); + + console.log('✅ 删除完成!'); + } catch (error) { + console.error('❌ 删除失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +deleteAllNotes(); diff --git a/backend/scripts/diagnose-generation-failure.ts b/backend/scripts/diagnose-generation-failure.ts new file mode 100644 index 0000000..cadb4b2 --- /dev/null +++ b/backend/scripts/diagnose-generation-failure.ts @@ -0,0 +1,144 @@ +/** + * 诊断生成失败问题 + * 检查最近的失败任务和错误信息 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function diagnose() { + try { + console.log('\n========== 诊断生成失败问题 ==========\n'); + + // 1. 检查最近的失败任务 + console.log('1. 最近的失败任务(最近10个):'); + const failedTasks = await prisma.aIContentTask.findMany({ + where: { + status: 'failed', + }, + orderBy: { + createdAt: 'desc', + }, + take: 10, + select: { + id: true, + status: true, + errorMessage: true, + generationStyle: true, + createdAt: true, + courseId: true, + }, + }); + + if (failedTasks.length === 0) { + console.log(' ✅ 没有失败的任务'); + } else { + failedTasks.forEach((task, index) => { + console.log(`\n 任务 ${index + 1}:`); + console.log(` ID: ${task.id}`); + console.log(` 状态: ${task.status}`); + console.log(` 错误信息: ${task.errorMessage || '无'}`); + console.log(` 生成风格: ${task.generationStyle || '无'}`); + console.log(` 创建时间: ${task.createdAt}`); + console.log(` 课程ID: ${task.courseId || '无'}`); + }); + } + + // 2. 检查最近的课程(包括失败状态) + console.log('\n\n2. 最近的课程(最近10个,包括生成状态):'); + const recentCourses = await prisma.course.findMany({ + orderBy: { + createdAt: 'desc', + }, + take: 10, + select: { + id: true, + title: true, + generationStatus: true, + generationProgress: true, + createdAt: true, + aiContentTask: { + select: { + id: true, + status: true, + errorMessage: true, + generationStyle: true, + }, + }, + }, + }); + + recentCourses.forEach((course, index) => { + console.log(`\n 课程 ${index + 1}:`); + console.log(` 标题: ${course.title}`); + console.log(` 生成状态: ${course.generationStatus || '无'}`); + console.log(` 生成进度: ${course.generationProgress || 0}`); + if (course.aiContentTask) { + console.log(` 任务状态: ${course.aiContentTask.status}`); + console.log(` 任务错误: ${course.aiContentTask.errorMessage || '无'}`); + console.log(` 任务风格: ${course.aiContentTask.generationStyle || '无'}`); + } else { + console.log(` 任务: 无`); + } + console.log(` 创建时间: ${course.createdAt}`); + }); + + // 3. 检查是否有 response_format 相关的错误 + console.log('\n\n3. 检查 response_format 相关错误:'); + const responseFormatErrors = failedTasks.filter( + task => task.errorMessage?.includes('response_format') || task.errorMessage?.includes('parameter') + ); + + if (responseFormatErrors.length === 0) { + console.log(' ✅ 没有 response_format 相关错误'); + } else { + console.log(` ⚠️ 发现 ${responseFormatErrors.length} 个 response_format 相关错误:`); + responseFormatErrors.forEach((task, index) => { + console.log(`\n 错误 ${index + 1}:`); + console.log(` 任务ID: ${task.id}`); + console.log(` 错误信息: ${task.errorMessage}`); + }); + } + + // 4. 检查最近的 SelectStyle 调用 + console.log('\n\n4. 检查最近的模式选择记录(通过任务状态变化):'); + const tasksWithStyle = await prisma.aIContentTask.findMany({ + where: { + generationStyle: { + not: null, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + take: 5, + select: { + id: true, + generationStyle: true, + status: true, + updatedAt: true, + }, + }); + + if (tasksWithStyle.length === 0) { + console.log(' ⚠️ 没有找到已选择模式的任务'); + } else { + tasksWithStyle.forEach((task, index) => { + console.log(`\n 任务 ${index + 1}:`); + console.log(` ID: ${task.id}`); + console.log(` 风格: ${task.generationStyle}`); + console.log(` 状态: ${task.status}`); + console.log(` 更新时间: ${task.updatedAt}`); + }); + } + + console.log('\n\n========== 诊断完成 ==========\n'); + } catch (error) { + console.error('诊断失败:', error); + } finally { + await prisma.$disconnect(); + } +} + +diagnose(); diff --git a/backend/scripts/diagnose-task-a5d0ec37.ts b/backend/scripts/diagnose-task-a5d0ec37.ts new file mode 100644 index 0000000..73f66ab --- /dev/null +++ b/backend/scripts/diagnose-task-a5d0ec37.ts @@ -0,0 +1,81 @@ +import prisma from '../src/utils/prisma'; +import { contentQueue } from '../src/services/queueService'; + +async function diagnose() { + const taskId = 'a5d0ec37-3abb-4463-b86a-c87cb3366a77'; + const courseId = '185f9e5e-e1c1-479b-abe6-224a29a68856'; + + console.log('=== 诊断任务状态 ===\n'); + + // 1. 检查任务状态 + const task = await prisma.aIContentTask.findUnique({ + where: { id: taskId }, + select: { + id: true, + status: true, + generationStyle: true, + outlineEssence: true, + outlineFull: true, + errorMessage: true, + courseId: true, + createdAt: true, + updatedAt: true, + }, + }); + + console.log('1. 任务状态:'); + console.log(JSON.stringify(task, null, 2)); + + // 2. 检查课程状态 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { + id: true, + title: true, + status: true, + generationStatus: true, + generationProgress: true, + totalNodes: true, + createdAt: true, + updatedAt: true, + }, + }); + + console.log('\n2. 课程状态:'); + console.log(JSON.stringify(course, null, 2)); + + // 3. 检查队列状态 + if (contentQueue) { + const jobId = `content-${taskId}`; + const job = await contentQueue.getJob(jobId); + + console.log('\n3. 队列任务状态:'); + if (job) { + const state = await job.getState(); + const progress = job.progress; + const failedReason = job.failedReason; + console.log(` Job ID: ${job.id}`); + console.log(` State: ${state}`); + console.log(` Progress: ${progress}`); + console.log(` Failed Reason: ${failedReason || 'N/A'}`); + console.log(` Data: ${JSON.stringify(job.data, null, 2)}`); + } else { + console.log(` 任务 ${jobId} 不在队列中(可能已完成、失败或被删除)`); + } + + const waiting = await contentQueue.getWaitingCount(); + const active = await contentQueue.getActiveCount(); + const completed = await contentQueue.getCompletedCount(); + const failed = await contentQueue.getFailedCount(); + + console.log('\n4. 内容生成队列统计:'); + console.log(` 等待中: ${waiting}`); + console.log(` 进行中: ${active}`); + console.log(` 已完成: ${completed}`); + console.log(` 失败: ${failed}`); + } + + await prisma.$disconnect(); +} + +diagnose().catch(console.error); diff --git a/backend/scripts/generate-missing-covers.ts b/backend/scripts/generate-missing-covers.ts new file mode 100644 index 0000000..152dcbb --- /dev/null +++ b/backend/scripts/generate-missing-covers.ts @@ -0,0 +1,78 @@ +/** + * 为没有封面的课程批量生成封面 + * 使用方法: npx ts-node scripts/generate-missing-covers.ts + */ + +import { PrismaClient } from '@prisma/client'; +import { generateCourseCover } from '../src/services/coverImageService'; +import { logger } from '../src/utils/logger'; + +const prisma = new PrismaClient(); + +async function generateMissingCovers() { + try { + // 1. 查找所有没有封面的课程(⚠️ 只处理没有封面的,不替换已有封面) + // 注意:用户可能已经上传了自己的封面,我们不应该替换它们 + const coursesWithoutCover = await prisma.course.findMany({ + where: { + deletedAt: null, + coverImage: null, + title: { not: '' }, + }, + take: 100, + }); + + logger.info(`[GenerateMissingCovers] 找到 ${coursesWithoutCover.length} 个需要生成/重新生成封面的课程`); + + if (coursesWithoutCover.length === 0) { + logger.info('[GenerateMissingCovers] 没有需要生成封面的课程'); + return; + } + + // 2. 为每个课程生成封面 + let successCount = 0; + let failCount = 0; + + for (const course of coursesWithoutCover) { + try { + const title = course.title || '未命名课程'; + logger.info(`[GenerateMissingCovers] 正在为课程生成封面: ${course.id}, title="${title}"`); + const coverImagePath = await generateCourseCover(course.id, title, 'full'); + + if (coverImagePath) { + // 更新数据库 + await prisma.course.update({ + where: { id: course.id }, + data: { coverImage: coverImagePath }, + }); + logger.info(`[GenerateMissingCovers] ✅ 封面生成成功: ${course.id}, path=${coverImagePath}`); + successCount++; + } else { + logger.warn(`[GenerateMissingCovers] ⚠️ 封面生成返回空路径: ${course.id}`); + failCount++; + } + } catch (error: any) { + logger.error(`[GenerateMissingCovers] ❌ 为课程生成封面失败: ${course.id}`, error); + failCount++; + } + } + + logger.info(`[GenerateMissingCovers] 完成!成功: ${successCount}, 失败: ${failCount}`); + } catch (error: any) { + logger.error('[GenerateMissingCovers] 批量生成封面失败', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行 +generateMissingCovers() + .then(() => { + console.log('✅ 批量生成封面完成'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ 批量生成封面失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/generate-xhs-cover-assets.ts b/backend/scripts/generate-xhs-cover-assets.ts new file mode 100644 index 0000000..561f0bd --- /dev/null +++ b/backend/scripts/generate-xhs-cover-assets.ts @@ -0,0 +1,32 @@ +/** + * 预生成所有小红书封面模板 PNG 到 public/xhs-covers/ + * 页面可直接用静态图,无需等 API 生成 + * + * 运行:npm run playground:xhs-generate + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { + generateXhsCover, + XHS_COVER_TEMPLATES, +} from '../src/services/xhsCoverTemplatesService'; + +async function main() { + const dir = path.join(process.cwd(), 'public', 'xhs-covers'); + await fs.mkdir(dir, { recursive: true }); + + for (const t of XHS_COVER_TEMPLATES) { + const buffer = generateXhsCover(t.id, {}); + const filepath = path.join(dir, `${t.id}.png`); + await fs.writeFile(filepath, buffer); + console.log(' ✓', t.name, `(${t.id}.png)`); + } + + console.log('\n✅ 已生成', XHS_COVER_TEMPLATES.length, '张模板图 → public/xhs-covers/'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/backend/scripts/insertSystemNotesToUserNodes.ts b/backend/scripts/insertSystemNotesToUserNodes.ts new file mode 100644 index 0000000..568ba06 --- /dev/null +++ b/backend/scripts/insertSystemNotesToUserNodes.ts @@ -0,0 +1,346 @@ +/** + * 在有用户笔记的节点插入系统笔记 + * 插入10条系统笔记到有用户笔记的节点 + */ + +import prisma from '../src/utils/prisma'; +import { SYSTEM_USER_ID } from '../src/constants'; +import { logger } from '../src/utils/logger'; + +/** + * 确保系统用户存在 + */ +async function ensureSystemUser() { + const existingUser = await prisma.user.findUnique({ + where: { id: SYSTEM_USER_ID }, + }); + + if (existingUser) { + logger.info('系统用户已存在'); + return existingUser; + } + + const systemUser = await prisma.user.create({ + data: { + id: SYSTEM_USER_ID, + nickname: '系统', + agreementAccepted: true, + }, + }); + + logger.info('系统用户创建成功'); + return systemUser; +} + +/** + * 查找有用户笔记的节点 + */ +async function findNodesWithUserNotes() { + // 查找有用户笔记的节点(排除系统笔记) + const notesWithNodes = await prisma.note.findMany({ + where: { + nodeId: { not: null }, + userId: { not: SYSTEM_USER_ID }, // 只统计用户笔记 + }, + select: { + nodeId: true, + }, + distinct: ['nodeId'], + take: 10, // 取前10个节点 + }); + + const nodeIds = notesWithNodes + .map((item) => item.nodeId) + .filter((id): id is string => id !== null); + + logger.info(`找到 ${nodeIds.length} 个有用户笔记的节点`); + + if (nodeIds.length === 0) { + // 如果没有用户笔记,使用"高效沟通的艺术"课程的前两节 + logger.info('没有找到用户笔记,使用"高效沟通的艺术"课程的前两节'); + const courseId = 'course_vertical_001'; + const node1Id = 'node_vertical_001_01'; + const node2Id = 'node_vertical_001_02'; + + const nodes = await prisma.courseNode.findMany({ + where: { + id: { in: [node1Id, node2Id] }, + }, + include: { + course: { + select: { + id: true, + title: true, + }, + }, + }, + }); + + return nodes; + } + + // 获取节点详情 + const nodes = await prisma.courseNode.findMany({ + where: { + id: { in: nodeIds }, + }, + include: { + course: { + select: { + id: true, + title: true, + }, + }, + }, + }); + + return nodes; +} + +/** + * 创建系统笔记 + */ +async function createSystemNote(data: { + nodeId: string; + courseId: string; + startIndex: number; + length: number; + type: 'highlight' | 'thought'; + content?: string; + quotedText: string; +}) { + // 检查是否已存在相同的系统笔记(避免重复创建) + const existing = await prisma.note.findFirst({ + where: { + userId: SYSTEM_USER_ID, + nodeId: data.nodeId, + startIndex: data.startIndex, + length: data.length, + type: data.type, + }, + }); + + if (existing) { + logger.info(`系统笔记已存在: ${data.quotedText.substring(0, 20)}...`); + return existing; + } + + const note = await prisma.note.create({ + data: { + userId: SYSTEM_USER_ID, + courseId: data.courseId, + nodeId: data.nodeId, + startIndex: data.startIndex, + length: data.length, + type: data.type, + content: data.content || '', + quotedText: data.quotedText, + }, + }); + + logger.info(`系统笔记创建成功: ${data.quotedText.substring(0, 20)}...`); + return note; +} + +/** + * 主函数 + */ +async function main() { + try { + logger.info('开始在有用户笔记的节点插入系统笔记...'); + + // 1. 确保系统用户存在 + await ensureSystemUser(); + + // 2. 查找有用户笔记的节点 + const nodes = await findNodesWithUserNotes(); + + if (nodes.length === 0) { + logger.info('没有找到有用户笔记的节点'); + return; + } + + // 3. 为每个节点创建系统笔记 + let createdCount = 0; + const targetCount = 10; + + for (const node of nodes) { + if (createdCount >= targetCount) { + break; + } + + logger.info(`为节点 "${node.title}" 创建系统笔记...`); + + // 获取该节点的用户笔记,用于生成系统笔记的位置 + const userNotes = await prisma.note.findMany({ + where: { + nodeId: node.id, + userId: { not: SYSTEM_USER_ID }, + }, + orderBy: { createdAt: 'asc' }, + take: 5, // 每个节点最多创建5条系统笔记 + }); + + logger.info(`节点 "${node.title}" 有 ${userNotes.length} 条用户笔记`); + + // 获取该节点的现有系统笔记,用于创建重叠的笔记 + const existingSystemNotes = await prisma.note.findMany({ + where: { + nodeId: node.id, + userId: SYSTEM_USER_ID, + }, + orderBy: { createdAt: 'asc' }, + take: 10, + }); + + logger.info(`节点 "${node.title}" 现有 ${existingSystemNotes.length} 条系统笔记`); + + // 为现有系统笔记创建重叠的笔记(如果有有效的startIndex) + for (const existingNote of existingSystemNotes) { + if (createdCount >= targetCount) { + break; + } + + if (existingNote.startIndex === null || existingNote.length === null) { + continue; // 跳过无效的笔记 + } + + // 如果 startIndex 是 0,说明是测试数据,跳过 + if (existingNote.startIndex === 0) { + continue; + } + + // 创建与现有系统笔记部分重叠的新系统笔记 + const overlapStart = existingNote.startIndex + Math.floor(existingNote.length / 2); + const systemNoteLength = Math.max(15, existingNote.length); + const systemNoteQuotedText = existingNote.quotedText + ? `${existingNote.quotedText.substring(Math.floor(existingNote.quotedText.length / 2))}(系统补充)` + : '系统笔记内容(重叠测试)'; + + const type = existingNote.type === 'highlight' ? 'thought' : 'highlight'; + const content = + type === 'thought' + ? '这是一个系统笔记,用于测试重叠显示逻辑。当与用户笔记重叠时,只显示用户笔记的线段。' + : undefined; + + await createSystemNote({ + nodeId: node.id, + courseId: node.courseId, + startIndex: overlapStart, + length: systemNoteLength, + type, + content, + quotedText: systemNoteQuotedText, + }); + + createdCount++; + } + + // 如果有用户笔记,为每个用户笔记创建系统笔记(部分重叠,部分不重叠) + if (userNotes.length > 0) { + // 有用户笔记的情况:为每个用户笔记创建系统笔记(部分重叠,部分不重叠) + for (let i = 0; i < userNotes.length && createdCount < targetCount; i++) { + const userNote = userNotes[i]; + + if (!userNote.startIndex || !userNote.length) { + continue; + } + + // 策略1:创建与用户笔记部分重叠的系统笔记(用于测试重叠逻辑) + if (i % 2 === 0 && createdCount < targetCount) { + // 重叠情况:系统笔记从用户笔记中间开始,延伸到后面 + const overlapStart = userNote.startIndex + Math.floor(userNote.length / 2); + const systemNoteLength = Math.max(15, userNote.length); + const systemNoteQuotedText = userNote.quotedText + ? `${userNote.quotedText.substring(Math.floor(userNote.quotedText.length / 2))}(系统补充)` + : '系统笔记内容(重叠测试)'; + + const type = 'highlight'; + await createSystemNote({ + nodeId: node.id, + courseId: node.courseId, + startIndex: overlapStart, + length: systemNoteLength, + type, + quotedText: systemNoteQuotedText, + }); + + createdCount++; + } + + // 策略2:创建不重叠的系统笔记(在用户笔记前面或后面) + if (createdCount < targetCount) { + // 在用户笔记前面创建系统笔记 + const beforeStartIndex = Math.max(0, userNote.startIndex - 30); + const beforeLength = Math.min(20, userNote.startIndex - beforeStartIndex); + + if (beforeLength > 5) { + const systemNoteQuotedText = '系统笔记:这是不重叠的部分'; + const type = Math.random() > 0.5 ? 'thought' : 'highlight'; + const content = + type === 'thought' + ? '这是一个系统笔记,用于测试非重叠显示逻辑。' + : undefined; + + await createSystemNote({ + nodeId: node.id, + courseId: node.courseId, + startIndex: beforeStartIndex, + length: beforeLength, + type, + content, + quotedText: systemNoteQuotedText, + }); + + createdCount++; + } + } + } + } + + // ✅ 如果还不够10条,直接创建系统笔记(不依赖现有笔记) + const notesPerNode = Math.ceil(targetCount / nodes.length); + const remainingCount = Math.min(notesPerNode, targetCount - createdCount); + + for (let i = 0; i < remainingCount && createdCount < targetCount; i++) { + const startIndex = 100 + i * 50; // 每个笔记间隔50个字符,从100开始 + const length = 20; + const systemNoteQuotedText = `系统笔记 ${createdCount + 1}:这是不重叠的部分`; + const type = createdCount % 2 === 0 ? 'thought' : 'highlight'; + const content = + type === 'thought' + ? `系统想法笔记 ${createdCount + 1}:这是一个系统笔记,用于测试非重叠显示逻辑。` + : undefined; + + await createSystemNote({ + nodeId: node.id, + courseId: node.courseId, + startIndex, + length, + type, + content, + quotedText: systemNoteQuotedText, + }); + + createdCount++; + } + } + + logger.info(`系统笔记创建完成!共创建 ${createdCount} 条系统笔记`); + } catch (error) { + logger.error('创建系统笔记失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 运行脚本 +main() + .then(() => { + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/backend/scripts/inspect-paragraphs-full.js b/backend/scripts/inspect-paragraphs-full.js new file mode 100644 index 0000000..7f7f486 --- /dev/null +++ b/backend/scripts/inspect-paragraphs-full.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +/** + * 彻底排查「段落间距」全链路: + * 1. 该课程所有节点:outline.suggestedContent 按双换行拆段数 vs node_slides.content.paragraphs 数 + * 2. 调用 lesson detail API,核对返回的 blocks 数量与内容 + * 用法:node scripts/inspect-paragraphs-full.js + * 例:node scripts/inspect-paragraphs-full.js 08253870-7499-4f39-9fa1-9cb76c280770 + */ + +const { PrismaClient } = require('@prisma/client'); +const http = require('http'); +const prisma = new PrismaClient(); + +const API_BASE = process.env.API_BASE || 'http://127.0.0.1:3000'; + +function get(url) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = http.request( + { hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET' }, + (res) => { + let body = ''; + res.on('data', (ch) => { body += ch; }); + res.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error('parse JSON: ' + body.slice(0, 200))); + } + }); + } + ); + req.on('error', reject); + req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); }); + req.end(); + }); +} + +async function main() { + const courseId = process.argv[2] ? process.argv[2].trim() : null; + if (!courseId) { + console.log('用法: node scripts/inspect-paragraphs-full.js '); + process.exit(1); + } + + const task = await prisma.courseGenerationTask.findFirst({ + where: { courseId }, + orderBy: { createdAt: 'desc' }, + include: { course: { select: { id: true, title: true } } }, + }); + if (!task) { + console.log('未找到 courseId 对应的任务:', courseId); + return; + } + + const outline = task.outline; + if (!outline || !outline.chapters || !Array.isArray(outline.chapters)) { + console.log('任务无 outline.chapters'); + return; + } + + const outlineNodes = []; + for (const ch of outline.chapters) { + if (ch.nodes && Array.isArray(ch.nodes)) { + for (const node of ch.nodes) { + outlineNodes.push({ + title: node.title || '', + suggestedContent: node.suggestedContent || '', + }); + } + } + } + + const dbNodes = await prisma.courseNode.findMany({ + where: { courseId }, + orderBy: { orderIndex: 'asc' }, + include: { slides: { orderBy: { orderIndex: 'asc' } } }, + }); + + console.log('=== 课程:', task.course?.title ?? courseId, '==='); + console.log('courseId:', courseId); + console.log('taskId:', task.id); + console.log('outline 小节总数:', outlineNodes.length); + console.log('DB 节点总数:', dbNodes.length); + console.log(''); + + const rows = []; + for (let i = 0; i < Math.max(outlineNodes.length, dbNodes.length); i++) { + const oNode = outlineNodes[i]; + const dNode = dbNodes[i]; + let outlineParagraphs = 0; + if (oNode && oNode.suggestedContent) { + const split = oNode.suggestedContent.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0); + outlineParagraphs = split.length; + } + let slideParagraphs = 0; + let nodeId = ''; + let nodeTitle = ''; + if (dNode) { + nodeId = dNode.id; + nodeTitle = dNode.title || ''; + const slide = dNode.slides && dNode.slides[0]; + if (slide && slide.content && typeof slide.content === 'object' && Array.isArray(slide.content.paragraphs)) { + slideParagraphs = slide.content.paragraphs.length; + } + } + const match = outlineParagraphs === slideParagraphs ? '✓' : '✗'; + rows.push({ + index: i + 1, + nodeId, + nodeTitle: nodeTitle.slice(0, 36) + (nodeTitle.length > 36 ? '…' : ''), + outlineParagraphs, + slideParagraphs, + match, + }); + } + + console.log('--- 全节点对比(outline 按双换行拆段数 vs node_slides.paragraphs 数)---'); + console.log('序号\toutline段数\tslide段数\t一致\t节点标题'); + rows.forEach((r) => { + console.log(`${r.index}\t${r.outlineParagraphs}\t\t${r.slideParagraphs}\t\t${r.match}\t${r.nodeTitle}`); + }); + const mismatchCount = rows.filter((r) => r.match === '✗').length; + console.log(''); + if (mismatchCount > 0) { + console.log('⚠️ 不一致节点数:', mismatchCount); + } else { + console.log('✓ 所有节点 outline 段数与 node_slides 段数一致'); + } + console.log(''); + + console.log('--- 调用 lesson detail API 核对(前 3 个节点)---'); + for (let i = 0; i < Math.min(3, dbNodes.length); i++) { + const node = dbNodes[i]; + const nodeId = node.id; + try { + const res = await get(`${API_BASE}/api/lessons/nodes/${nodeId}/detail`); + if (!res || !res.success || !res.data) { + console.log(`节点 ${i + 1} (${nodeId}): API 返回异常`, res ? (res.error || res) : 'null'); + continue; + } + const blocks = res.data.blocks || []; + const blockTypes = blocks.map((b) => b.type).join(', '); + console.log(`节点 ${i + 1}: ${node.title?.slice(0, 32)}…`); + console.log(` nodeId: ${nodeId}`); + console.log(` API blocks 数量: ${blocks.length}`); + console.log(` block types: ${blockTypes}`); + blocks.slice(0, 2).forEach((b, bi) => { + console.log(` block[${bi}] type=${b.type} contentLen=${(b.content || '').length} contentPreview=${JSON.stringify((b.content || '').slice(0, 50))}`); + }); + const expectedBlocks = rows[i] ? rows[i].slideParagraphs : 0; + if (blocks.length !== expectedBlocks) { + console.log(` ⚠️ 与 node_slides.paragraphs 数(${expectedBlocks}) 不一致`); + } + console.log(''); + } catch (e) { + console.log(`节点 ${i + 1} (${nodeId}): 请求失败`, e.message); + console.log(''); + } + } + + console.log('--- 小结 ---'); + console.log('1. 上表:outline 段数 = suggestedContent 按 /\\n\\s*\\n/ 拆分后的段落数;slide 段数 = node_slides.content.paragraphs 长度'); + console.log('2. getLessonDetail 会把每个 slide 的 content.paragraphs 逐条转成 paragraph block,故 blocks 数应等于该节点 slide 的 paragraphs 数'); + console.log('3. iOS ContentBlockBuilder 在 index>0 的 block 前插入 paragraphSpacing(24pt),多段即有多段间距'); +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/inspect-paragraphs-server.js b/backend/scripts/inspect-paragraphs-server.js new file mode 100644 index 0000000..bc467f0 --- /dev/null +++ b/backend/scripts/inspect-paragraphs-server.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * 排查「段落间距」:对比 调用记录里 outline.suggestedContent 与 node_slides.content.paragraphs + * 在服务器上执行(需能连到数据库): + * cd /var/www/wildgrowth-backend/backend && node scripts/inspect-paragraphs-server.js + * 本地(有 .env): + * cd backend && node scripts/inspect-paragraphs-server.js + */ + +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + const argId = process.argv[2] ? process.argv[2].trim() : null; + + let task; + if (argId) { + // 先按 taskId 查,再按 courseId 查 + task = await prisma.courseGenerationTask.findFirst({ + where: { id: argId }, + include: { course: { select: { id: true, title: true } } }, + }); + if (!task) { + task = await prisma.courseGenerationTask.findFirst({ + where: { courseId: argId }, + orderBy: { createdAt: 'desc' }, + include: { course: { select: { id: true, title: true } } }, + }); + } + if (!task) { + console.log('未找到 taskId 或 courseId 为', argId, '的任务'); + return; + } + } else { + // 取最新一条已完成任务 + task = await prisma.courseGenerationTask.findFirst({ + where: { status: 'completed' }, + orderBy: { createdAt: 'desc' }, + include: { course: { select: { id: true, title: true } } }, + }); + if (!task) { + console.log('没有已完成的生成任务'); + return; + } + } + + console.log('=== 任务信息 ==='); + console.log('taskId:', task.id); + console.log('courseId:', task.courseId); + console.log('courseTitle:', task.course?.title ?? ''); + console.log(''); + + const outline = task.outline; + if (!outline || !outline.chapters || !Array.isArray(outline.chapters)) { + console.log('任务无 outline 或 chapters'); + return; + } + + // 取第一个小节对应的 suggestedContent(调用记录里显示的那类内容) + let firstSuggestedContent = null; + let firstNodeTitle = ''; + for (const ch of outline.chapters) { + if (ch.nodes && Array.isArray(ch.nodes)) { + for (const node of ch.nodes) { + if (node.suggestedContent) { + firstSuggestedContent = node.suggestedContent; + firstNodeTitle = node.title || ''; + break; + } + } + if (firstSuggestedContent) break; + } + } + + if (!firstSuggestedContent) { + console.log('outline 中未找到 suggestedContent'); + } else { + console.log('=== outline 中第一个小节的 suggestedContent(调用记录里看到的内容)==='); + console.log('小节标题:', firstNodeTitle); + console.log('长度:', firstSuggestedContent.length); + const idx = firstSuggestedContent.indexOf('\n'); + console.log('第一个 \\n 位置:', idx === -1 ? '无' : idx); + const doubleNewlineCount = (firstSuggestedContent.match(/\n\s*\n/g) || []).length; + console.log('双换行 \\n\\n 出现次数:', doubleNewlineCount); + const splitByDouble = firstSuggestedContent.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0); + console.log('按 /\\n\\s*\\n/ 拆分后段落数:', splitByDouble.length); + console.log('前 350 字符(repr):', JSON.stringify(firstSuggestedContent.slice(0, 350))); + console.log(''); + } + + // 该课程下第一个节点的 node_slides 的 content.paragraphs + const firstNode = await prisma.courseNode.findFirst({ + where: { courseId: task.courseId }, + orderBy: { orderIndex: 'asc' }, + include: { slides: { orderBy: { orderIndex: 'asc' }, take: 1 } }, + }); + + if (!firstNode || !firstNode.slides || firstNode.slides.length === 0) { + console.log('该课程下没有节点或没有 node_slides'); + return; + } + + const slide = firstNode.slides[0]; + const content = slide.content; + console.log('=== 该课程第一个节点的 node_slides.content(实际落库)==='); + console.log('节点标题:', firstNode.title); + console.log('slide id:', slide.id); + if (content && typeof content === 'object' && Array.isArray(content.paragraphs)) { + console.log('paragraphs 数量:', content.paragraphs.length); + content.paragraphs.forEach((p, i) => { + console.log(` [${i}] 长度: ${p.length}, 前80字: ${JSON.stringify(p.slice(0, 80))}`); + }); + } else { + console.log('content 或 content.paragraphs 不存在:', content ? Object.keys(content) : 'null'); + } +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/inspect-quotes-server.js b/backend/scripts/inspect-quotes-server.js new file mode 100644 index 0000000..796bbc2 --- /dev/null +++ b/backend/scripts/inspect-quotes-server.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * 查看最近 3 条已完成课程的 outline 与正文样本,检查单引号 '' 出现位置 + * 用法:node scripts/inspect-quotes-server.js + */ + +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +function hasSingleQuotes(s) { + if (!s || typeof s !== 'string') return false; + const single = ["'", "'", "'", "''", "''", "`"]; + return single.some((c) => s.includes(c)); +} + +function showQuotes(s, label) { + if (!s) return; + const str = String(s); + if (!hasSingleQuotes(str)) return; + const idx = str.search(/[''`]/); + if (idx === -1) return; + const slice = str.slice(Math.max(0, idx - 20), idx + 30); + console.log(` [单引号] ${label}: ...${slice}...`); +} + +async function main() { + const tasks = await prisma.courseGenerationTask.findMany({ + where: { status: 'completed' }, + orderBy: { createdAt: 'desc' }, + take: 3, + include: { course: { select: { id: true, title: true } } }, + }); + + if (tasks.length === 0) { + console.log('没有已完成的课程'); + return; + } + + console.log('=== 最近 3 条已完成课程:单引号检查 ===\n'); + + for (let t = 0; t < tasks.length; t++) { + const task = tasks[t]; + const outline = task.outline; + console.log(`--- 课程 ${t + 1}: ${task.course?.title ?? task.courseId} ---`); + console.log('courseId:', task.courseId); + console.log('createdAt:', task.createdAt?.toISOString?.() ?? task.createdAt); + + if (outline && outline.chapters && Array.isArray(outline.chapters)) { + for (let c = 0; c < outline.chapters.length; c++) { + const ch = outline.chapters[c]; + const parentTitle = ch.parent_title ?? ch.title ?? ''; + if (hasSingleQuotes(parentTitle)) { + console.log(` 章节标题含单引号: "${parentTitle}"`); + showQuotes(parentTitle, '章节'); + } + if (ch.nodes && Array.isArray(ch.nodes)) { + for (let n = 0; n < ch.nodes.length; n++) { + const node = ch.nodes[n]; + const nodeTitle = node.title ?? ''; + if (hasSingleQuotes(nodeTitle)) { + console.log(` 节点标题含单引号: "${nodeTitle}"`); + showQuotes(nodeTitle, '节点'); + } + const content = node.suggestedContent ?? ''; + if (hasSingleQuotes(content)) { + showQuotes(content, '正文'); + } + } + } + } + // 只打印第一个小节的正文前 400 字,便于看单引号 + let firstContent = null; + for (const ch of outline.chapters) { + if (ch.nodes && ch.nodes[0] && ch.nodes[0].suggestedContent) { + firstContent = ch.nodes[0].suggestedContent; + break; + } + } + if (firstContent && hasSingleQuotes(firstContent)) { + console.log(' 首段正文中含单引号,片段:'); + const singleIdx = firstContent.search(/[''`]/); + if (singleIdx !== -1) { + console.log(' ', firstContent.slice(Math.max(0, singleIdx - 25), singleIdx + 40)); + } + } + } + console.log(''); + } +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/migrate-digital-id.ts b/backend/scripts/migrate-digital-id.ts new file mode 100644 index 0000000..5e1f89f --- /dev/null +++ b/backend/scripts/migrate-digital-id.ts @@ -0,0 +1,73 @@ +import prisma from '../src/utils/prisma'; +import { generateUniqueDigitalId } from '../src/utils/digitalIdGenerator'; + +/** + * 为现有用户生成 digitalId + * 此脚本用于数据库迁移,为所有没有 digitalId 的用户生成唯一的 ID + */ +async function migrateDigitalIds() { + try { + console.log('🚀 开始迁移 digitalId...'); + + // 查找所有没有 digitalId 的用户 + const users = await prisma.user.findMany({ + where: { digitalId: null }, + }); + + console.log(`📊 找到 ${users.length} 个需要生成 digitalId 的用户`); + + if (users.length === 0) { + console.log('✅ 所有用户都已拥有 digitalId,无需迁移'); + return; + } + + let successCount = 0; + let failCount = 0; + + for (const user of users) { + try { + // 生成唯一的 digitalId + const digitalId = await generateUniqueDigitalId(); + + // 更新用户 + await prisma.user.update({ + where: { id: user.id }, + data: { digitalId }, + }); + + console.log(`✅ 为用户 ${user.id} (${user.nickname || '未设置昵称'}) 生成 digitalId: ${digitalId}`); + successCount++; + } catch (error: any) { + console.error(`❌ 为用户 ${user.id} 生成 digitalId 失败: ${error.message}`); + failCount++; + } + } + + console.log('\n📊 迁移完成统计:'); + console.log(` ✅ 成功: ${successCount} 个用户`); + console.log(` ❌ 失败: ${failCount} 个用户`); + console.log(` 📝 总计: ${users.length} 个用户`); + + if (failCount > 0) { + console.log('\n⚠️ 有部分用户迁移失败,请检查日志并手动处理'); + } else { + console.log('\n🎉 所有用户迁移成功!'); + } + } catch (error) { + console.error('❌ 迁移过程中发生错误:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行迁移 +migrateDigitalIds() + .then(() => { + console.log('✅ 迁移脚本执行完成'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ 迁移脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/migrate-prompt-configs.ts b/backend/scripts/migrate-prompt-configs.ts new file mode 100644 index 0000000..b39d8a8 --- /dev/null +++ b/backend/scripts/migrate-prompt-configs.ts @@ -0,0 +1,111 @@ +/** + * 迁移Prompt配置脚本 + * 将 book_content_prompt 重命名为 text_parse_prompt + * 添加 direct_generation_prompt 和 continue_course_prompt 的默认值 + */ + +import prisma from '../src/utils/prisma'; +import { logger } from '../src/utils/logger'; +import { + PROMPT_KEYS, + DEFAULT_TEXT_PARSE_PROMPT, + DEFAULT_DIRECT_GENERATION_PROMPT, + DEFAULT_CONTINUE_COURSE_PROMPT, +} from '../src/services/promptConfigService'; + +async function migratePromptConfigs() { + try { + logger.info('[迁移脚本] 开始迁移Prompt配置...'); + + // 1. 迁移 book_content_prompt → text_parse_prompt + const oldPrompt = await prisma.appConfig.findUnique({ + where: { key: 'book_content_prompt' }, + }); + + if (oldPrompt) { + // 如果存在旧配置,迁移到新key + await prisma.appConfig.upsert({ + where: { key: PROMPT_KEYS.TEXT_PARSE }, + create: { + key: PROMPT_KEYS.TEXT_PARSE, + value: oldPrompt.value, + updatedAt: new Date(), + }, + update: { + value: oldPrompt.value, + updatedAt: new Date(), + }, + }); + logger.info('[迁移脚本] ✅ book_content_prompt 已迁移到 text_parse_prompt'); + } else { + // 如果不存在,创建默认值 + await prisma.appConfig.upsert({ + where: { key: PROMPT_KEYS.TEXT_PARSE }, + create: { + key: PROMPT_KEYS.TEXT_PARSE, + value: DEFAULT_TEXT_PARSE_PROMPT, + updatedAt: new Date(), + }, + update: { + value: DEFAULT_TEXT_PARSE_PROMPT, + updatedAt: new Date(), + }, + }); + logger.info('[迁移脚本] ✅ text_parse_prompt 已创建默认值'); + } + + // 2. 添加 direct_generation_prompt(如果不存在) + const directPrompt = await prisma.appConfig.findUnique({ + where: { key: PROMPT_KEYS.DIRECT_GENERATION }, + }); + + if (!directPrompt) { + await prisma.appConfig.create({ + data: { + key: PROMPT_KEYS.DIRECT_GENERATION, + value: DEFAULT_DIRECT_GENERATION_PROMPT, + updatedAt: new Date(), + }, + }); + logger.info('[迁移脚本] ✅ direct_generation_prompt 已创建默认值'); + } else { + logger.info('[迁移脚本] ℹ️ direct_generation_prompt 已存在,跳过'); + } + + // 3. 添加 continue_course_prompt(如果不存在) + const continuePrompt = await prisma.appConfig.findUnique({ + where: { key: PROMPT_KEYS.CONTINUE_COURSE }, + }); + + if (!continuePrompt) { + await prisma.appConfig.create({ + data: { + key: PROMPT_KEYS.CONTINUE_COURSE, + value: DEFAULT_CONTINUE_COURSE_PROMPT, + updatedAt: new Date(), + }, + }); + logger.info('[迁移脚本] ✅ continue_course_prompt 已创建默认值'); + } else { + logger.info('[迁移脚本] ℹ️ continue_course_prompt 已存在,跳过'); + } + + logger.info('[迁移脚本] ✅ Prompt配置迁移完成'); + } catch (error: any) { + logger.error(`[迁移脚本] ❌ 迁移失败: ${error.message}`, error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行迁移 +migratePromptConfigs() + .then(() => { + console.log('✅ 迁移完成'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ 迁移失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/regenerate-all-covers.ts b/backend/scripts/regenerate-all-covers.ts new file mode 100644 index 0000000..8e3e324 --- /dev/null +++ b/backend/scripts/regenerate-all-covers.ts @@ -0,0 +1,92 @@ +/** + * 重新生成所有课程的封面图 + * 使用方法: npx ts-node scripts/regenerate-all-covers.ts + */ + +import { PrismaClient } from '@prisma/client'; +import { generateCourseCover } from '../src/services/coverImageService'; +import { logger } from '../src/utils/logger'; + +const prisma = new PrismaClient(); + +async function regenerateAllCovers() { + try { + // 1. 查找所有未删除的课程(包括已有封面的) + const allCourses = await prisma.course.findMany({ + where: { + deletedAt: null, + title: { + not: '', + }, + }, + include: { + aiContentTask: { + select: { + generationStyle: true, + }, + }, + }, + }); + + logger.info(`[RegenerateAllCovers] 找到 ${allCourses.length} 个课程,将重新生成所有封面`); + + if (allCourses.length === 0) { + logger.info('[RegenerateAllCovers] 没有需要生成封面的课程'); + return; + } + + // 2. 为每个课程重新生成封面 + let successCount = 0; + let failCount = 0; + + for (const course of allCourses) { + try { + const title = course.title || '未命名课程'; + // 从任务中获取生成风格,如果没有则默认为 'full' + const style = (course.aiContentTask?.generationStyle as 'full' | 'essence' | 'one-page') || 'full'; + + logger.info(`[RegenerateAllCovers] 正在重新生成封面: ${course.id}, title="${title}", style=${style}`); + + const coverImagePath = await generateCourseCover( + course.id, + title, + style + ); + + if (coverImagePath) { + // 更新数据库 + await prisma.course.update({ + where: { id: course.id }, + data: { coverImage: coverImagePath }, + }); + logger.info(`[RegenerateAllCovers] ✅ 封面重新生成成功: ${course.id}, path=${coverImagePath}`); + successCount++; + } else { + logger.warn(`[RegenerateAllCovers] ⚠️ 封面生成返回空路径: ${course.id}`); + failCount++; + } + } catch (error: any) { + logger.error(`[RegenerateAllCovers] ❌ 为课程重新生成封面失败: ${course.id}`, error); + failCount++; + } + } + + logger.info(`[RegenerateAllCovers] 完成!成功: ${successCount}, 失败: ${failCount}`); + } catch (error: any) { + logger.error('[RegenerateAllCovers] 批量重新生成封面失败', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行 +regenerateAllCovers() + .then(() => { + console.log('✅ 批量重新生成封面完成'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ 批量重新生成封面失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/regression-test.ts b/backend/scripts/regression-test.ts new file mode 100644 index 0000000..114b07f --- /dev/null +++ b/backend/scripts/regression-test.ts @@ -0,0 +1,120 @@ +import prisma from '../src/utils/prisma'; +import { logger } from '../src/utils/logger'; + +/** + * 回归测试:检查核心表查询是否正常 + */ +async function regressionTest() { + const results: { test: string; passed: boolean; message: string }[] = []; + + try { + logger.info('🧪 开始回归测试...'); + + // 1. 测试Course表查询 + logger.info('1. 测试Course表查询...'); + try { + const courses = await prisma.course.findMany({ + take: 5, + include: { + nodes: true, + chapters: true, + }, + }); + results.push({ + test: 'Course表查询', + passed: true, + message: `✅ 成功查询 ${courses.length} 个课程`, + }); + logger.info(` ✅ 成功查询 ${courses.length} 个课程`); + } catch (error: any) { + results.push({ + test: 'Course表查询', + passed: false, + message: `❌ 失败: ${error.message}`, + }); + logger.error(` ❌ Course表查询失败: ${error.message}`); + } + + // 2. 测试UserLearningProgress查询 + logger.info('2. 测试UserLearningProgress查询...'); + try { + const progress = await prisma.userLearningProgress.findMany({ + take: 5, + include: { + node: { + include: { + course: true, + }, + }, + }, + }); + results.push({ + test: 'UserLearningProgress查询', + passed: true, + message: `✅ 成功查询 ${progress.length} 条学习进度记录`, + }); + logger.info(` ✅ 成功查询 ${progress.length} 条学习进度记录`); + } catch (error: any) { + results.push({ + test: 'UserLearningProgress查询', + passed: false, + message: `❌ 失败: ${error.message}`, + }); + logger.error(` ❌ UserLearningProgress查询失败: ${error.message}`); + } + + // 3. 测试User表查询 + logger.info('3. 测试User表查询...'); + try { + const users = await prisma.user.findMany({ + take: 5, + include: { + learningProgress: true, + courses: true, + }, + }); + results.push({ + test: 'User表查询', + passed: true, + message: `✅ 成功查询 ${users.length} 个用户`, + }); + logger.info(` ✅ 成功查询 ${users.length} 个用户`); + } catch (error: any) { + results.push({ + test: 'User表查询', + passed: false, + message: `❌ 失败: ${error.message}`, + }); + logger.error(` ❌ User表查询失败: ${error.message}`); + } + + // 总结 + logger.info(''); + logger.info('📊 回归测试结果:'); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + results.forEach((r) => { + logger.info(` ${r.passed ? '✅' : '❌'} ${r.test}: ${r.message}`); + }); + logger.info(''); + logger.info(`总计: ${passed} 通过, ${failed} 失败`); + + return failed === 0; + } catch (error: any) { + logger.error(`❌ 回归测试执行失败: ${error.message}`); + logger.error(error.stack); + return false; + } finally { + await prisma.$disconnect(); + } +} + +// 运行测试 +regressionTest() + .then((success) => { + process.exit(success ? 0 : 1); + }) + .catch((error) => { + logger.error(`测试脚本执行失败: ${error.message}`); + process.exit(1); + }); diff --git a/backend/scripts/stats-lite-duration.js b/backend/scripts/stats-lite-duration.js new file mode 100644 index 0000000..c22655a --- /dev/null +++ b/backend/scripts/stats-lite-duration.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * 最近用 doubao lite 模型跑出来的已完成课程:平均生成时间、90 分位(秒) + * 用法:node scripts/stats-lite-duration.js + */ + +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + const tasks = await prisma.courseGenerationTask.findMany({ + where: { + status: 'completed', + modelId: { contains: 'lite' }, + }, + orderBy: { createdAt: 'desc' }, + take: 50, + select: { id: true, courseId: true, modelId: true, createdAt: true, updatedAt: true }, + }); + + if (tasks.length === 0) { + console.log('没有用 doubao lite 完成的课程'); + return; + } + + const durations = tasks + .map((t) => (t.updatedAt && t.createdAt ? (new Date(t.updatedAt) - new Date(t.createdAt)) / 1000 : null)) + .filter((d) => d != null); + + if (durations.length === 0) { + console.log('无有效时长数据'); + return; + } + + const sorted = [...durations].sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b, 0); + const avg = sum / sorted.length; + const idx90 = Math.min(Math.ceil(0.9 * sorted.length) - 1, sorted.length - 1); + const p90 = sorted[idx90]; + + console.log('=== doubao lite 模型:最近已完成课程生成时间 ===\n'); + console.log('条数:', sorted.length); + console.log('平均(秒):', Math.round(avg * 10) / 10); + console.log('90分位(秒):', Math.round(p90 * 10) / 10); + console.log('\n明细(秒):', sorted.map((d) => Math.round(d)).join(', ')); +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/stats-models-100.js b/backend/scripts/stats-models-100.js new file mode 100644 index 0000000..a0e292e --- /dev/null +++ b/backend/scripts/stats-models-100.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * 拉取最近 100 条已完成课程,按模型对比:生成时间、字数 + * 用法:node scripts/stats-models-100.js + */ + +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +function countOutlineChars(outline) { + if (!outline || !outline.chapters || !Array.isArray(outline.chapters)) return 0; + let total = 0; + for (const ch of outline.chapters) { + if (ch.nodes && Array.isArray(ch.nodes)) { + for (const node of ch.nodes) { + const s = node.suggestedContent; + if (s && typeof s === 'string') total += s.length; + } + } + } + return total; +} + +function modelLabel(modelId) { + if (!modelId) return '未知'; + if (modelId.includes('lite')) return 'lite'; + if (modelId.includes('flash')) return 'flash'; + return modelId; +} + +async function main() { + const tasks = await prisma.courseGenerationTask.findMany({ + where: { status: 'completed' }, + orderBy: { createdAt: 'desc' }, + take: 100, + include: { course: { select: { id: true, title: true } } }, + }); + + if (tasks.length === 0) { + console.log('没有已完成的课程'); + return; + } + + const rows = tasks.map((t) => { + const durationSec = t.updatedAt && t.createdAt + ? Math.round((new Date(t.updatedAt) - new Date(t.createdAt)) / 1000) + : null; + const chars = countOutlineChars(t.outline); + return { + courseId: t.courseId, + title: (t.course?.title || '课程创建中').slice(0, 28), + modelId: t.modelId || null, + model: modelLabel(t.modelId), + createdAt: t.createdAt.toISOString().replace('T', ' ').slice(0, 19), + durationSec, + chars, + }; + }); + + const byModel = {}; + for (const r of rows) { + const k = r.model; + if (!byModel[k]) byModel[k] = { count: 0, totalDuration: 0, totalChars: 0, durations: [], chars: [] }; + byModel[k].count++; + if (r.durationSec != null) { + byModel[k].totalDuration += r.durationSec; + byModel[k].durations.push(r.durationSec); + } + byModel[k].totalChars += r.chars; + byModel[k].chars.push(r.chars); + } + + console.log('=== 最近 100 条已完成课程:按模型对比 ===\n'); + console.log('--- 按模型汇总 ---'); + console.log('模型\t\t条数\t平均生成时间(秒)\t总字数\t平均字数'); + for (const [model, s] of Object.entries(byModel)) { + const avgDur = s.count > 0 && s.totalDuration > 0 ? Math.round(s.totalDuration / s.count) : '-'; + const avgChars = s.count > 0 ? Math.round(s.totalChars / s.count) : 0; + console.log(`${model}\t\t${s.count}\t${avgDur}\t\t\t${s.totalChars}\t${avgChars}`); + } + + console.log('\n--- 明细表(最近 100 条:课程标题、模型、创建时间、生成时间(秒)、字数)---'); + console.log('序号\t课程标题\t\t\t模型\t创建时间\t\t生成(秒)\t字数'); + rows.forEach((r, i) => { + const title = (r.title + '\t').slice(0, 20); + const model = (r.model || '未知').padEnd(6); + const dur = r.durationSec != null ? String(r.durationSec) : '-'; + console.log(`${i + 1}\t${title}\t${model}\t${r.createdAt}\t${dur}\t\t${r.chars}`); + }); +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/test-chat-vs-completions.ts b/backend/scripts/test-chat-vs-completions.ts new file mode 100644 index 0000000..0be7d32 --- /dev/null +++ b/backend/scripts/test-chat-vs-completions.ts @@ -0,0 +1,146 @@ +/** + * 对比测试:chat/completions vs completions 接口耗时 + * 使用书籍解析 Prompt + 同一段样本文本,各调用 3 次,记录耗时并汇总。 + */ + +import OpenAI from 'openai'; +import * as dotenv from 'dotenv'; +import { DEFAULT_BOOK_CONTENT_PROMPT } from '../src/services/bookPromptConfigService'; + +dotenv.config(); + +// 与 modelProvider 一致:无 env 时用同一 fallback(仅便于在服务器跑对比测试) +const DOUBAO_API_KEY = process.env.DOUBAO_API_KEY || 'a3e13a85-437f-448c-aaa9-14292cd5e0ab'; +const DOUBAO_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'; +const DOUBAO_MODEL = 'doubao-seed-1-6-lite-251015'; + +// 与书籍解析一致的 system:把 {{sourceText}} 替换为「见下文」 +const SYSTEM_PROMPT = DEFAULT_BOOK_CONTENT_PROMPT.replace(/\{\{sourceText\}\}/g, '见下文'); + +// 固定样本文本(单层结构,便于模型快速输出) +const SAMPLE_SOURCE_TEXT = `第一章 什么是有效沟通 + +有效沟通是人与人之间准确传递信息、情感与意图的过程。它包含清晰表达、积极倾听和及时反馈。 + +第二章 倾听的重要性 + +倾听不仅是听到对方说了什么,更要理解其背后的需求与情绪。主动倾听能让对方感到被尊重,减少误解。 + +第三章 反馈的技巧 + +反馈要具体、及时、对事不对人。用「我观察到…」「我的感受是…」等表述,能降低对方的防御心理。`; + +const RUNS = 3; +const MAX_TOKENS = 2000; + +async function run() { + if (!DOUBAO_API_KEY) { + console.error('请设置 DOUBAO_API_KEY(.env 或环境变量)'); + process.exit(1); + } + // 若使用 fallback key,仅打印提示,不暴露完整 key + const keySource = process.env.DOUBAO_API_KEY ? 'env' : 'fallback'; + console.log('API Key 来源:', keySource); + + const client = new OpenAI({ + apiKey: DOUBAO_API_KEY, + baseURL: DOUBAO_BASE_URL, + }); + + console.log('═══════════════════════════════════════════════════════════'); + console.log(' 书籍解析 Prompt 对比:chat/completions vs completions'); + console.log(' 同一文本,各调用 ' + RUNS + ' 次'); + console.log('═══════════════════════════════════════════════════════════'); + console.log('Base URL:', DOUBAO_BASE_URL); + console.log('Model:', DOUBAO_MODEL); + console.log('样本文本长度:', SAMPLE_SOURCE_TEXT.length, '字符'); + console.log('max_tokens:', MAX_TOKENS); + console.log(''); + + const chatDurations: number[] = []; + const compDurations: number[] = []; + + // ---------- 1) Chat Completions,调用 RUNS 次 ---------- + console.log('--- /chat/completions 调用 ' + RUNS + ' 次 ---'); + for (let i = 0; i < RUNS; i++) { + try { + const t0 = Date.now(); + await client.chat.completions.create({ + model: DOUBAO_MODEL, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: SAMPLE_SOURCE_TEXT }, + ], + max_tokens: MAX_TOKENS, + temperature: 0.7, + }); + const ms = Date.now() - t0; + chatDurations.push(ms); + console.log(' 第 ' + (i + 1) + ' 次: ' + ms + ' ms'); + } catch (e: any) { + console.log(' 第 ' + (i + 1) + ' 次: 失败 -', e?.message || e); + } + } + console.log(''); + + // ---------- 2) Completions(合并为单条 prompt),调用 RUNS 次 ---------- + const singlePrompt = + SYSTEM_PROMPT + '\n\n--- 用户消息(原始材料)---\n\n' + SAMPLE_SOURCE_TEXT; + console.log('--- /completions 调用 ' + RUNS + ' 次 ---'); + for (let i = 0; i < RUNS; i++) { + try { + const t0 = Date.now(); + await client.completions.create({ + model: DOUBAO_MODEL, + prompt: singlePrompt, + max_tokens: MAX_TOKENS, + temperature: 0.7, + }); + const ms = Date.now() - t0; + compDurations.push(ms); + console.log(' 第 ' + (i + 1) + ' 次: ' + ms + ' ms'); + } catch (e: any) { + console.log(' 第 ' + (i + 1) + ' 次: 失败 -', e?.message || e); + } + } + console.log(''); + + // ---------- 汇总 ---------- + console.log('═══════════════════════════════════════════════════════════'); + console.log(' 汇总'); + console.log('═══════════════════════════════════════════════════════════'); + if (chatDurations.length > 0) { + const sum = chatDurations.reduce((a, b) => a + b, 0); + const avg = Math.round(sum / chatDurations.length); + console.log('chat/completions: 成功 ' + chatDurations.length + ' 次'); + console.log(' 各次(ms): ' + chatDurations.join(', ')); + console.log(' 平均(ms): ' + avg); + } else { + console.log('chat/completions: 无成功调用'); + } + console.log(''); + if (compDurations.length > 0) { + const sum = compDurations.reduce((a, b) => a + b, 0); + const compAvg = sum / compDurations.length; + console.log('completions: 成功 ' + compDurations.length + ' 次'); + console.log(' 各次(ms): ' + compDurations.join(', ')); + console.log(' 平均(ms): ' + Math.round(compAvg)); + if (chatDurations.length > 0) { + const chatAvg = + chatDurations.reduce((a, b) => a + b, 0) / chatDurations.length; + const diff = Math.round(compAvg - chatAvg); + const pct = chatAvg > 0 ? ((diff / chatAvg) * 100).toFixed(1) : '0'; + console.log( + '与 chat 平均差异: ' + (diff >= 0 ? '+' : '') + diff + ' ms (' + pct + '%)' + ); + } + } else { + console.log('completions: 无成功调用(豆包可能未开放此端点)'); + } + console.log('═══════════════════════════════════════════════════════════'); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/backend/scripts/test-chunking-file.ts b/backend/scripts/test-chunking-file.ts new file mode 100644 index 0000000..bb84f41 --- /dev/null +++ b/backend/scripts/test-chunking-file.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env npx ts-node +/** + * 本地测试脚本:解析文档并分块 + * 用法: npx ts-node scripts/test-chunking-file.ts <文件路径> + * 支持: .docx, .pdf, .epub + */ + +import fs from 'fs'; +import path from 'path'; +import pdfParse from 'pdf-parse'; +import mammoth from 'mammoth'; +import EPub from 'epub'; +import { structureChunkingService } from '../src/services/structureChunkingService'; + +async function parseFile(filePath: string): Promise { + const ext = path.extname(filePath).toLowerCase(); + const buffer = fs.readFileSync(filePath); + + if (ext === '.docx') { + const result = await mammoth.extractRawText({ buffer }); + return result.value; + } + + if (ext === '.pdf') { + const data = await pdfParse(buffer); + return data.text; + } + + if (ext === '.epub') { + return new Promise((resolve, reject) => { + const epub = new EPub(filePath); + epub.on('end', async () => { + const chapters: string[] = []; + for (const item of epub.flow || []) { + try { + const text = await new Promise((res) => { + epub.getChapter(item.id, (err: any, t: string) => res(err ? '' : t || '')); + }); + if (text.trim()) chapters.push(text.trim()); + } catch {} + } + resolve(chapters.join('\n\n')); + }); + epub.on('error', reject); + epub.parse(); + }); + } + + throw new Error(`不支持的格式: ${ext}`); +} + +async function main() { + const filePath = process.argv[2]; + if (!filePath) { + console.log('用法: npx ts-node scripts/test-chunking-file.ts <文件路径>'); + console.log('支持: .docx, .pdf, .epub'); + process.exit(1); + } + + if (!fs.existsSync(filePath)) { + console.error(`文件不存在: ${filePath}`); + process.exit(1); + } + + console.log('═'.repeat(60)); + console.log(`📄 文件: ${path.basename(filePath)}`); + console.log('═'.repeat(60)); + + try { + // 1. 解析文档 + console.log('\n⏳ 解析文档...'); + const text = await parseFile(filePath); + console.log(`✅ 解析完成: ${text.length.toLocaleString()} 字符`); + + // 2. 分块(使用 LLM 增强版) + console.log('\n⏳ 执行分块(LLM 增强版)...'); + const result = await structureChunkingService.parseAsync(text); + + // 3. 输出结果 + console.log('\n' + '─'.repeat(60)); + console.log('📊 分块结果'); + console.log('─'.repeat(60)); + console.log(` 识别模式: ${result.pattern || '(无结构)'}`); + console.log(` 分块数量: ${result.chunks.length}`); + console.log(` 总字符数: ${result.totalCharacters.toLocaleString()}`); + + if (result.chunks.length > 0) { + console.log('\n' + '─'.repeat(60)); + console.log('📋 分块列表'); + console.log('─'.repeat(60)); + + result.chunks.forEach((chunk, i) => { + const preview = chunk.content.replace(/\s+/g, ' ').substring(0, 60); + console.log(`\n[${i + 1}] ${chunk.title}`); + console.log(` 字符: ${chunk.content.length.toLocaleString()}`); + console.log(` 预览: ${preview}...`); + }); + + // 4. 潜在问题检测 + console.log('\n' + '─'.repeat(60)); + console.log('🔍 潜在问题检测'); + console.log('─'.repeat(60)); + + let issues: string[] = []; + + // 检查分块数量异常 + if (result.chunks.length > 50) { + issues.push(`⚠️ 分块数量较多 (${result.chunks.length}),可能存在误匹配`); + } + if (result.chunks.length === 1 && result.totalCharacters > 5000) { + issues.push(`⚠️ 只有1个分块但内容很长,可能未正确识别结构`); + } + + // 检查分块大小差异 + const sizes = result.chunks.map(c => c.content.length); + const avgSize = sizes.reduce((a, b) => a + b, 0) / sizes.length; + const tooSmall = sizes.filter(s => s < 100).length; + const tooLarge = sizes.filter(s => s > avgSize * 5).length; + + if (tooSmall > 0) { + issues.push(`⚠️ ${tooSmall} 个分块内容过短 (<100字符),可能是误匹配`); + } + if (tooLarge > 0) { + issues.push(`⚠️ ${tooLarge} 个分块内容过长,分块可能不均匀`); + } + + // 检查标题异常 + const shortTitles = result.chunks.filter(c => c.title.length < 3); + if (shortTitles.length > 0) { + issues.push(`⚠️ ${shortTitles.length} 个分块标题过短`); + } + + // 检查重复标题 + const titleSet = new Set(result.chunks.map(c => c.title)); + if (titleSet.size < result.chunks.length) { + issues.push(`⚠️ 存在重复标题,可能是目录或列表被误匹配`); + } + + if (issues.length === 0) { + console.log(' ✅ 未发现明显问题'); + } else { + issues.forEach(issue => console.log(` ${issue}`)); + } + + // 5. 显示前5个分块的完整标题 + console.log('\n' + '─'.repeat(60)); + console.log('📝 前10个分块标题(完整)'); + console.log('─'.repeat(60)); + result.chunks.slice(0, 10).forEach((chunk, i) => { + console.log(` ${i + 1}. ${chunk.title}`); + }); + if (result.chunks.length > 10) { + console.log(` ... 还有 ${result.chunks.length - 10} 个`); + } + } + + console.log('\n' + '═'.repeat(60)); + + } catch (error: any) { + console.error(`❌ 处理失败: ${error.message}`); + process.exit(1); + } +} + +main(); diff --git a/backend/scripts/test-course-generation-direct.sh b/backend/scripts/test-course-generation-direct.sh new file mode 100755 index 0000000..20c6906 --- /dev/null +++ b/backend/scripts/test-course-generation-direct.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# 直接测试课程生成API(需要先获取Token) +# 使用方法:先手动登录获取Token,然后运行此脚本 + +BASE_URL="https://api.muststudy.xin" + +echo "🧪 测试课程生成完整流程" +echo "📍 API: $BASE_URL" +echo "" +echo "⚠️ 注意:此脚本需要先手动获取Token" +echo " 请先登录获取Token,然后设置环境变量:" +echo " export TEST_TOKEN='your_token_here'" +echo "" + +if [ -z "$TEST_TOKEN" ]; then + echo "❌ 未设置 TEST_TOKEN 环境变量" + echo "" + echo "获取Token的方法:" + echo "1. 使用手机号登录(需要验证码)" + echo "2. 或者使用现有的测试账号" + echo "" + echo "示例:" + echo " export TEST_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'" + echo " bash scripts/test-course-generation-direct.sh" + exit 1 +fi + +echo "✅ 使用Token: ${TEST_TOKEN:0:20}..." +echo "" + +# 1. 创建课程 +echo "1️⃣ 创建课程(精华版)..." +CREATE_RESPONSE=$(curl -s -X POST "$BASE_URL/api/ai/content/upload" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TEST_TOKEN" \ + -d '{ + "content": "社交是每个人都需要掌握的重要技能。在现代社会,良好的人际关系不仅能帮助我们获得更多机会,还能提升我们的生活质量。社交的核心在于建立有意义的人际关系,这种关系应该基于相互尊重、价值交换和长期维护。", + "style": "essence" + }') + +echo "创建响应: $CREATE_RESPONSE" | head -c 300 +echo "" + +COURSE_ID=$(echo "$CREATE_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('data', {}).get('courseId', ''))" 2>/dev/null) +TASK_ID=$(echo "$CREATE_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('data', {}).get('taskId', ''))" 2>/dev/null) + +if [ -z "$COURSE_ID" ]; then + echo "❌ 创建课程失败" + echo "完整响应: $CREATE_RESPONSE" + exit 1 +fi + +echo "" +echo "✅ 课程创建成功" +echo " Course ID: $COURSE_ID" +echo " Task ID: $TASK_ID" +echo "" + +# 2. 查询任务详情 +echo "2️⃣ 查询任务详情..." +TASK_RESPONSE=$(curl -s -X GET "$BASE_URL/api/ai/content/tasks/$TASK_ID" \ + -H "Authorization: Bearer $TEST_TOKEN") + +TASK_STATUS=$(echo "$TASK_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('data', {}).get('task', {}).get('status', 'N/A'))" 2>/dev/null) +TASK_TITLE=$(echo "$TASK_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('data', {}).get('task', {}).get('suggestedTitle', 'N/A'))" 2>/dev/null) + +echo " 任务状态: $TASK_STATUS" +echo " 建议标题: $TASK_TITLE" +echo "" + +# 3. 轮询查询状态 +echo "3️⃣ 轮询查询生成状态(最多等待3分钟)..." +MAX_ITERATIONS=60 +LAST_PROGRESS="" +for i in $(seq 1 $MAX_ITERATIONS); do + sleep 3 + + STATUS_RESPONSE=$(curl -s -X GET "$BASE_URL/api/my-courses" \ + -H "Authorization: Bearer $TEST_TOKEN") + + # 提取进度 + PROGRESS=$(echo "$STATUS_RESPONSE" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + courses = data.get('data', {}).get('courses', []) + for course in courses: + if course.get('id') == '$COURSE_ID': + progress = course.get('generation_progress') + status = course.get('generation_status') or course.get('status') + title = course.get('title', 'N/A') + error = course.get('error_message') + if progress is not None: + print(f\"{int(progress * 100)}|{status}|{title}|{error or ''}\") + else: + print(f\"N/A|{status}|{title}|{error or ''}\") + break +except Exception as e: + pass +" 2>/dev/null) + + if [ -n "$PROGRESS" ] && [ "$PROGRESS" != "$LAST_PROGRESS" ]; then + PROG_VAL=$(echo "$PROGRESS" | cut -d'|' -f1) + STATUS_VAL=$(echo "$PROGRESS" | cut -d'|' -f2) + TITLE_VAL=$(echo "$PROGRESS" | cut -d'|' -f3) + ERROR_VAL=$(echo "$PROGRESS" | cut -d'|' -f4) + + if [ "$PROG_VAL" != "N/A" ]; then + echo " [$i/$MAX_ITERATIONS] 进度: ${PROG_VAL}% | 状态: $STATUS_VAL | 标题: $TITLE_VAL" + else + echo " [$i/$MAX_ITERATIONS] 状态: $STATUS_VAL | 标题: $TITLE_VAL" + fi + + if [ -n "$ERROR_VAL" ]; then + echo " ⚠️ 错误信息: $ERROR_VAL" + fi + + LAST_PROGRESS="$PROGRESS" + + if [ "$STATUS_VAL" = "completed" ]; then + echo "" + echo "✅ 课程生成完成!" + break + fi + + if [ "$STATUS_VAL" = "failed" ]; then + echo "" + echo "❌ 课程生成失败" + break + fi + fi +done + +echo "" +echo "4️⃣ 查询任务日志..." +LOGS_RESPONSE=$(curl -s -X GET "$BASE_URL/api/ai/prompts/logs?taskId=$TASK_ID&limit=10" \ + -H "Authorization: Bearer $TEST_TOKEN") + +LOG_INFO=$(echo "$LOGS_RESPONSE" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + logs = data.get('data', {}).get('logs', []) + print(f'总数: {len(logs)}') + for log in logs[:10]: + prompt_type = log.get('promptType', 'N/A') + status = log.get('status', 'N/A') + duration = log.get('duration', 0) + tokens = log.get('tokensUsed', 0) + error = log.get('errorMessage', '') + print(f\" - {prompt_type} ({status}) - {duration}ms - {tokens}tokens\") + if error: + print(f\" 错误: {error}\") +except Exception as e: + print(f'解析失败: {e}') +" 2>/dev/null) + +echo "$LOG_INFO" +echo "" +echo "✅ 测试完成!" +echo "" +echo "📊 测试总结:" +echo " - Course ID: $COURSE_ID" +echo " - Task ID: $TASK_ID" +echo " - 最终状态: $STATUS_VAL" +echo " - 最终标题: $TITLE_VAL" diff --git a/backend/scripts/test-course-generation-flow.ts b/backend/scripts/test-course-generation-flow.ts new file mode 100755 index 0000000..4a325ac --- /dev/null +++ b/backend/scripts/test-course-generation-flow.ts @@ -0,0 +1,382 @@ +/** + * 测试课程生成完整流程 + * 使用方法:ts-node backend/scripts/test-course-generation-flow.ts + */ + +import axios from 'axios'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +// 加载环境变量 +dotenv.config({ path: path.join(__dirname, '../.env.test') }); + +const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000'; +const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL || 'test@example.com'; +const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || 'test123456'; + +interface CreateCourseResponse { + success: boolean; + data: { + courseId: string; + taskId: string; + status: string; + generationStyle?: string; + message?: string; + }; +} + +interface CourseStatusResponse { + success: boolean; + data: { + courses: Array<{ + id: string; + title: string; + generation_status: string | null; + generation_progress: number | null; + status: string; + progress: number; + error_message?: string | null; + }>; + }; +} + +interface TaskResponse { + success: boolean; + data: { + task: { + id: string; + status: string; + generationStyle: string | null; + outline: any; + suggestedTitle: string | null; + errorMessage: string | null; + }; + }; +} + +interface PromptLogResponse { + success: boolean; + data: { + logs: Array<{ + id: string; + promptType: string; + taskId: string | null; + status: string; + duration: number | null; + tokensUsed: number | null; + errorMessage: string | null; + createdAt: string; + }>; + total: number; + }; +} + +// 测试内容 +const TEST_CONTENT = ` +# 如何有效社交 + +社交是每个人都需要掌握的重要技能。在现代社会,良好的人际关系不仅能帮助我们获得更多机会,还能提升我们的生活质量。 + +## 第一章:理解社交的本质 + +社交不仅仅是简单的聊天和应酬,它更是一种建立信任、传递价值的过程。真正的社交应该是双向的,既要有给予,也要有收获。 + +### 1.1 社交的核心价值 + +社交的核心在于建立有意义的人际关系。这种关系应该基于: +- 相互尊重 +- 价值交换 +- 长期维护 + +### 1.2 常见的社交误区 + +很多人对社交存在误解: +- 认为社交就是应酬和喝酒 +- 认为社交需要花费大量时间 +- 认为社交是外向者的专利 + +## 第二章:提升社交能力的方法 + +### 2.1 培养倾听能力 + +倾听是社交的基础。一个好的倾听者应该: +- 专注对方的表达 +- 理解对方的情绪 +- 给予适当的反馈 + +### 2.2 学会表达自己 + +表达自己同样重要: +- 清晰表达观点 +- 适当展示价值 +- 保持真诚和自然 + +## 第三章:维护人际关系 + +### 3.1 定期联系 + +不要等到需要帮助时才联系朋友: +- 定期问候 +- 分享有价值的信息 +- 在对方需要时提供帮助 + +### 3.2 维护关系 + +关系的维护需要: +- 时间和精力 +- 真诚的态度 +- 持续的价值交换 +`; + +async function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +interface LoginResponse { + success: boolean; + data: { + token: string; + user: any; + }; +} + +async function login(): Promise { + console.log('🔐 登录获取 Token...'); + + // 先尝试注册(如果用户不存在) + try { + console.log('📝 尝试注册测试用户...'); + await axios.post(`${BASE_URL}/api/auth/register`, { + email: TEST_USER_EMAIL, + password: TEST_USER_PASSWORD, + username: '测试用户', + }); + console.log('✅ 注册成功'); + } catch (regError: any) { + if (regError.response?.status === 400 && regError.response?.data?.message?.includes('已存在')) { + console.log('ℹ️ 用户已存在,直接登录'); + } else { + console.log(`⚠️ 注册失败(可能用户已存在): ${regError.response?.data?.message || regError.message}`); + } + } + + // 登录 + try { + const response = await axios.post(`${BASE_URL}/api/auth/login`, { + email: TEST_USER_EMAIL, + password: TEST_USER_PASSWORD, + }); + + if (response.data.success && response.data.data.token) { + console.log('✅ 登录成功'); + return response.data.data.token; + } else { + throw new Error('登录失败:未返回 Token'); + } + } catch (error: any) { + const errorMsg = error.response?.data?.message || error.message; + throw new Error(`登录失败: ${errorMsg}`); + } +} + +async function createCourse(token: string, style: 'full' | 'essence' | 'one-page' = 'essence'): Promise { + console.log(`\n📝 创建课程(风格: ${style})...`); + + const response = await axios.post( + `${BASE_URL}/api/ai/content/upload`, + { + content: TEST_CONTENT, + style: style, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.data.success) { + throw new Error('创建课程失败'); + } + + console.log(`✅ 课程创建成功:`); + console.log(` - Course ID: ${response.data.data.courseId}`); + console.log(` - Task ID: ${response.data.data.taskId}`); + console.log(` - 状态: ${response.data.data.status}`); + + return response.data.data; +} + +async function checkCourseStatus(token: string, courseId: string, maxWait: number = 300000): Promise { + console.log(`\n⏳ 等待课程生成完成(最多 ${maxWait / 1000} 秒)...`); + + const startTime = Date.now(); + let lastProgress = -1; + + while (Date.now() - startTime < maxWait) { + try { + const response = await axios.get( + `${BASE_URL}/api/my-courses`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const course = response.data.data.courses.find(c => c.id === courseId); + + if (!course) { + console.log('⚠️ 课程未找到,继续等待...'); + await sleep(3000); + continue; + } + + const progress = course.generation_progress ? Math.round(course.generation_progress * 100) : 0; + + if (progress !== lastProgress) { + console.log(`📊 生成进度: ${progress}% (状态: ${course.generation_status || course.status})`); + lastProgress = progress; + } + + if (course.generation_status === 'completed' || course.status === 'completed') { + console.log(`\n✅ 课程生成完成!`); + console.log(` - 标题: ${course.title}`); + console.log(` - 最终状态: ${course.status}`); + return; + } + + if (course.generation_status === 'failed' || course.status === 'failed') { + console.log(`\n❌ 课程生成失败!`); + console.log(` - 错误信息: ${course.error_message || '未知错误'}`); + throw new Error(`生成失败: ${course.error_message || '未知错误'}`); + } + + await sleep(3000); + } catch (error: any) { + if (error.response?.status === 401) { + throw new Error('Token 已过期,请重新登录'); + } + console.log(`⚠️ 查询状态失败: ${error.message},继续重试...`); + await sleep(3000); + } + } + + throw new Error('生成超时'); +} + +async function checkTaskStatus(token: string, taskId: string): Promise { + console.log(`\n📋 查询任务详情 (Task ID: ${taskId})...`); + + try { + const response = await axios.get( + `${BASE_URL}/api/ai/content/tasks/${taskId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const task = response.data.data.task; + console.log(`✅ 任务状态:`); + console.log(` - 状态: ${task.status}`); + console.log(` - 风格: ${task.generationStyle || '未设置'}`); + console.log(` - 标题: ${task.suggestedTitle || '未生成'}`); + console.log(` - 大纲: ${task.outline ? '已生成' : '未生成'}`); + if (task.errorMessage) { + console.log(` - 错误: ${task.errorMessage}`); + } + } catch (error: any) { + console.log(`⚠️ 查询任务失败: ${error.message}`); + } +} + +async function checkPromptLogs(token: string, taskId: string): Promise { + console.log(`\n📝 查询 AI 调用日志 (Task ID: ${taskId})...`); + + try { + const response = await axios.get( + `${BASE_URL}/api/ai/prompts/logs?taskId=${taskId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const logs = response.data.data.logs; + console.log(`✅ 找到 ${logs.length} 条日志记录:`); + + logs.forEach((log, index) => { + console.log(`\n ${index + 1}. ${log.promptType} (${log.status})`); + console.log(` - 耗时: ${log.duration ? `${log.duration}ms` : 'N/A'}`); + console.log(` - Tokens: ${log.tokensUsed || 'N/A'}`); + console.log(` - 时间: ${new Date(log.createdAt).toLocaleString()}`); + if (log.errorMessage) { + console.log(` - 错误: ${log.errorMessage}`); + } + }); + } catch (error: any) { + console.log(`⚠️ 查询日志失败: ${error.message}`); + } +} + +async function main() { + console.log('🧪 开始测试课程生成完整流程\n'); + console.log(`📍 API 地址: ${BASE_URL}`); + console.log(`👤 测试用户: ${TEST_USER_EMAIL}\n`); + + try { + // 1. 登录 + const token = await login(); + + // 2. 创建课程(测试三种风格) + const styles: Array<'full' | 'essence' | 'one-page'> = ['essence', 'full', 'one-page']; + + for (const style of styles) { + console.log(`\n${'='.repeat(60)}`); + console.log(`测试风格: ${style}`); + console.log('='.repeat(60)); + + try { + // 创建课程 + const { courseId, taskId } = await createCourse(token, style); + + // 查询任务状态 + await checkTaskStatus(token, taskId); + + // 等待生成完成 + await checkCourseStatus(token, courseId); + + // 查询日志 + await checkPromptLogs(token, taskId); + + console.log(`\n✅ 风格 ${style} 测试完成!`); + + // 等待一下再进行下一个测试 + if (style !== styles[styles.length - 1]) { + console.log('\n⏸️ 等待 5 秒后进行下一个测试...'); + await sleep(5000); + } + } catch (error: any) { + console.error(`\n❌ 风格 ${style} 测试失败: ${error.message}`); + // 继续测试下一个风格 + } + } + + console.log(`\n${'='.repeat(60)}`); + console.log('🎉 所有测试完成!'); + console.log('='.repeat(60)); + + } catch (error: any) { + console.error(`\n❌ 测试失败: ${error.message}`); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } +} + +// 运行测试 +main().catch(console.error); diff --git a/backend/scripts/test-course-generation.sh b/backend/scripts/test-course-generation.sh new file mode 100755 index 0000000..2f42f62 --- /dev/null +++ b/backend/scripts/test-course-generation.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# 完整的课程生成流程测试脚本 + +BASE_URL="https://api.muststudy.xin" +EMAIL="test$(date +%s)@example.com" +PASSWORD="test123456" + +echo "🧪 测试课程生成完整流程" +echo "📍 API: $BASE_URL" +echo "👤 测试用户: $EMAIL" +echo "" + +# 1. 注册并登录 +echo "1️⃣ 注册并登录..." +REGISTER_RESPONSE=$(curl -s -X POST "$BASE_URL/api/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"username\":\"测试用户\"}") + +echo "注册响应: $REGISTER_RESPONSE" | head -c 200 +echo "" + +TOKEN=$(curl -s -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | \ + python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('data', {}).get('token', ''))" 2>/dev/null) + +if [ -z "$TOKEN" ]; then + echo "❌ 登录失败,尝试直接解析..." + LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}") + echo "登录响应: $LOGIN_RESPONSE" + exit 1 +fi + +echo "✅ 登录成功,Token: ${TOKEN:0:20}..." +echo "" + +# 2. 创建课程 +echo "2️⃣ 创建课程(精华版)..." +CREATE_RESPONSE=$(curl -s -X POST "$BASE_URL/api/ai/content/upload" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "content": "社交是每个人都需要掌握的重要技能。在现代社会,良好的人际关系不仅能帮助我们获得更多机会,还能提升我们的生活质量。", + "style": "essence" + }') + +COURSE_ID=$(echo "$CREATE_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('data', {}).get('courseId', ''))" 2>/dev/null) +TASK_ID=$(echo "$CREATE_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('data', {}).get('taskId', ''))" 2>/dev/null) + +if [ -z "$COURSE_ID" ]; then + echo "❌ 创建课程失败" + echo "响应: $CREATE_RESPONSE" + exit 1 +fi + +echo "✅ 课程创建成功" +echo " Course ID: $COURSE_ID" +echo " Task ID: $TASK_ID" +echo "" + +# 3. 轮询查询状态 +echo "3️⃣ 轮询查询生成状态(最多等待2分钟)..." +MAX_ITERATIONS=40 +for i in $(seq 1 $MAX_ITERATIONS); do + sleep 3 + + STATUS_RESPONSE=$(curl -s -X GET "$BASE_URL/api/my-courses" \ + -H "Authorization: Bearer $TOKEN") + + # 提取进度 + PROGRESS=$(echo "$STATUS_RESPONSE" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + courses = data.get('data', {}).get('courses', []) + for course in courses: + if course.get('id') == '$COURSE_ID': + progress = course.get('generation_progress') + status = course.get('generation_status') or course.get('status') + title = course.get('title', 'N/A') + if progress is not None: + print(f\"{int(progress * 100)}|{status}|{title}\") + else: + print(f\"N/A|{status}|{title}\") + break +except: + pass +" 2>/dev/null) + + if [ -n "$PROGRESS" ]; then + PROG_VAL=$(echo "$PROGRESS" | cut -d'|' -f1) + STATUS_VAL=$(echo "$PROGRESS" | cut -d'|' -f2) + TITLE_VAL=$(echo "$PROGRESS" | cut -d'|' -f3) + + if [ "$PROG_VAL" != "N/A" ]; then + echo " [$i/$MAX_ITERATIONS] 进度: ${PROG_VAL}% | 状态: $STATUS_VAL | 标题: $TITLE_VAL" + else + echo " [$i/$MAX_ITERATIONS] 状态: $STATUS_VAL | 标题: $TITLE_VAL" + fi + + if [ "$STATUS_VAL" = "completed" ] || [ "$STATUS_VAL" = "已完成" ]; then + echo "" + echo "✅ 课程生成完成!" + break + fi + + if [ "$STATUS_VAL" = "failed" ] || [ "$STATUS_VAL" = "失败" ]; then + echo "" + echo "❌ 课程生成失败" + break + fi + else + echo " [$i/$MAX_ITERATIONS] 查询中..." + fi +done + +echo "" +echo "4️⃣ 查询任务日志..." +LOGS_RESPONSE=$(curl -s -X GET "$BASE_URL/api/ai/prompts/logs?taskId=$TASK_ID" \ + -H "Authorization: Bearer $TOKEN") + +LOG_COUNT=$(echo "$LOGS_RESPONSE" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + logs = data.get('data', {}).get('logs', []) + print(len(logs)) + for log in logs[:5]: + print(f\" - {log.get('promptType')} ({log.get('status')}) - {log.get('duration')}ms\") +except: + print('0') +" 2>/dev/null) + +echo "找到 $LOG_COUNT 条日志记录" +echo "" +echo "✅ 测试完成!" diff --git a/backend/scripts/test-created-by-visibility.ts b/backend/scripts/test-created-by-visibility.ts new file mode 100644 index 0000000..575c9b6 --- /dev/null +++ b/backend/scripts/test-created-by-visibility.ts @@ -0,0 +1,420 @@ +/** + * 测试 createdBy 和 visibility 功能 + * 测试场景: + * 1. 用户创建课程(AI生成)- 应该设置 createdBy 和 visibility='private',生成完成后自动发布 + * 2. 查询课程列表 - 应该只看到公开课程和自己创建的课程 + * 3. 修改自己创建的课程 - 应该成功 + * 4. 修改他人创建的课程 - 应该失败(403) + * 5. 删除自己创建的课程 - 应该成功 + * 6. 删除他人创建的课程 - 应该失败(403) + */ + +import axios from 'axios'; +import * as dotenv from 'dotenv'; +import { join } from 'path'; + +// 加载环境变量 +dotenv.config({ path: join(__dirname, '../.env.test') }); + +const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000'; +const TEST_EMAIL = process.env.TEST_USER_EMAIL || `test_${Date.now()}@example.com`; +const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Test123456!'; + +let authToken: string = ''; +let userId: string = ''; +let createdCourseId: string = ''; +let otherUserToken: string = ''; +let otherUserId: string = ''; + +interface LoginResponse { + success: boolean; + data: { + token: string; + user: { + id: string; + email: string; + }; + }; +} + +interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +interface CreateCourseResponse { + success: boolean; + data: { + courseId: string; + taskId: string; + status: string; + }; +} + +interface CourseResponse { + success: boolean; + data: { + course: { + id: string; + title: string; + created_by?: string; + visibility?: string; + status?: string; + }; + }; +} + +interface CourseListResponse { + success: boolean; + data: { + courses: Array<{ + id: string; + title: string; + visibility?: string; + }>; + }; +} + +async function registerUser(email: string, password: string): Promise { + try { + // 尝试注册,如果接口不存在或用户已存在,则跳过 + await axios.post(`${BASE_URL}/api/auth/register`, { + email, + password, + username: `测试用户_${Date.now()}`, + }); + console.log(`✅ 用户注册成功: ${email}`); + } catch (error: any) { + if (error.response?.status === 404) { + // 注册接口不存在,跳过注册,直接尝试登录 + console.log(`ℹ️ 注册接口不存在,跳过注册: ${email}`); + } else if (error.response?.status === 400 && (error.response?.data?.message?.includes('已存在') || error.response?.data?.message?.includes('exists'))) { + console.log(`ℹ️ 用户已存在: ${email}`); + } else { + // 其他错误也忽略,直接尝试登录 + console.log(`ℹ️ 注册失败,将尝试登录: ${error.response?.status || error.message}`); + } + } +} + +async function login(email: string, password: string): Promise<{ token: string; userId: string }> { + // 尝试使用测试 token + try { + const testTokenResponse = await axios.get<{ success: boolean; data: { token: string; user: { id: string } } }>(`${BASE_URL}/api/auth/test-token`); + if (testTokenResponse.data.success && testTokenResponse.data.data.token) { + console.log(`✅ 使用测试 token 登录成功`); + return { + token: testTokenResponse.data.data.token, + userId: testTokenResponse.data.data.user.id, + }; + } + } catch (error) { + console.log(`ℹ️ 测试 token 不可用,尝试其他方式`); + } + + // 如果测试 token 不可用,尝试普通登录(需要手机号和验证码) + const response = await axios.post(`${BASE_URL}/api/auth/login`, { + email, + password, + }); + + if (!response.data.success || !response.data.data.token) { + throw new Error('登录失败'); + } + + return { + token: response.data.data.token, + userId: response.data.data.user.id, + }; +} + +async function testCreateCourse() { + console.log('\n📝 测试 1: 用户创建课程(AI生成)'); + + const content = `# 测试课程 + +这是一个测试课程的内容,用于验证 createdBy 和 visibility 功能。 + +## 第一章:基础概念 + +这是第一章的内容。 + +## 第二章:进阶应用 + +这是第二章的内容。 +`; + + const response = await axios.post( + `${BASE_URL}/api/ai/content/upload`, + { + content, + style: 'essence', + }, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (response.data.success && response.data.data.courseId) { + createdCourseId = response.data.data.courseId; + console.log(`✅ 课程创建成功,courseId: ${createdCourseId}`); + console.log(` taskId: ${response.data.data.taskId}`); + console.log(` status: ${response.data.data.status}`); + + // 等待一下,让课程创建完成 + await new Promise(resolve => setTimeout(resolve, 2000)); + + // 查询课程详情,验证 createdBy 和 visibility + const courseResponse = await axios.get( + `${BASE_URL}/api/courses/${createdCourseId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + const course = courseResponse.data.data.course; + console.log(`\n📋 课程信息验证:`); + console.log(` createdBy: ${course.created_by || 'null'} (应该是: ${userId})`); + console.log(` visibility: ${course.visibility || 'N/A'} (应该是: private)`); + console.log(` status: ${course.status || 'N/A'}`); + + if (course.created_by === userId && course.visibility === 'private') { + console.log(`✅ createdBy 和 visibility 设置正确`); + } else { + console.log(`❌ createdBy 或 visibility 设置错误`); + } + } else { + throw new Error('创建课程失败'); + } +} + +async function testQueryCourses() { + console.log('\n📋 测试 2: 查询课程列表'); + + // 测试公开接口(未登录) + const publicResponse = await axios.get(`${BASE_URL}/api/courses`); + const publicCourses = publicResponse.data.data.courses || []; + console.log(`✅ 公开接口返回 ${publicCourses.length} 个课程`); + console.log(` 所有课程 visibility 应该是 'public': ${publicCourses.every((c: any) => c.visibility === 'public' || !c.visibility)}`); + + // 测试登录用户接口 + const userResponse = await axios.get(`${BASE_URL}/api/courses`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + const userCourses = userResponse.data.data.courses || []; + console.log(`✅ 登录用户接口返回 ${userCourses.length} 个课程`); + + // 验证应该包含自己创建的课程 + const hasMyCourse = userCourses.some((c: any) => c.id === createdCourseId); + console.log(` 包含自己创建的课程: ${hasMyCourse ? '✅' : '❌'}`); + + // 测试 my-courses 接口 + const myCoursesResponse = await axios.get(`${BASE_URL}/api/my-courses`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + const myCourses = myCoursesResponse.data.data.courses || []; + console.log(`✅ /api/my-courses 返回 ${myCourses.length} 个课程`); + const hasMyCourseInList = myCourses.some((c: any) => c.id === createdCourseId); + console.log(` 包含自己创建的课程: ${hasMyCourseInList ? '✅' : '❌'}`); +} + +async function testUpdateOwnCourse() { + console.log('\n✏️ 测试 3: 修改自己创建的课程'); + + try { + const response = await axios.put( + `${BASE_URL}/api/courses/${createdCourseId}`, + { + title: '修改后的测试课程标题', + description: '这是修改后的描述', + }, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (response.data.success) { + console.log(`✅ 修改自己创建的课程成功`); + console.log(` 新标题: ${response.data.data.course.title}`); + } else { + const errorMsg = (response.data as any).message || '未知错误'; + console.log(`❌ 修改失败: ${errorMsg}`); + } + } catch (error: any) { + if (error.response?.status === 403) { + console.log(`❌ 修改失败: 权限不足(403)`); + } else { + console.log(`❌ 修改失败: ${error.message}`); + } + } +} + +async function testUpdateOtherUserCourse() { + console.log('\n✏️ 测试 4: 尝试修改他人创建的课程(应该失败)'); + + // 先创建一个其他用户的课程(如果还没有) + if (!otherUserToken) { + const otherEmail = `other_${Date.now()}@example.com`; + await registerUser(otherEmail, TEST_PASSWORD); + const loginResult = await login(otherEmail, TEST_PASSWORD); + otherUserToken = loginResult.token; + otherUserId = loginResult.userId; + } + + // 尝试修改第一个用户创建的课程 + try { + await axios.put( + `${BASE_URL}/api/courses/${createdCourseId}`, + { + title: '尝试修改他人课程', + }, + { + headers: { + Authorization: `Bearer ${otherUserToken}`, + }, + } + ); + console.log(`❌ 不应该成功修改他人课程`); + } catch (error: any) { + if (error.response?.status === 403) { + console.log(`✅ 正确拒绝修改他人课程(403 Forbidden)`); + } else { + console.log(`⚠️ 返回了其他错误: ${error.response?.status} - ${error.message}`); + } + } +} + +async function testDeleteOwnCourse() { + console.log('\n🗑️ 测试 5: 删除自己创建的课程'); + + // 先创建一个用于删除的课程 + const content = `# 待删除的测试课程`; + const createResponse = await axios.post( + `${BASE_URL}/api/ai/content/upload`, + { + content, + style: 'essence', + }, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (createResponse.data.success) { + const courseIdToDelete = createResponse.data.data.courseId; + console.log(` 创建了用于删除的课程: ${courseIdToDelete}`); + + // 等待课程创建完成 + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + const response = await axios.delete( + `${BASE_URL}/api/courses/${courseIdToDelete}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (response.data.success) { + console.log(`✅ 删除自己创建的课程成功`); + } else { + console.log(`❌ 删除失败: ${response.data.message || '未知错误'}`); + } + } catch (error: any) { + if (error.response?.status === 403) { + console.log(`❌ 删除失败: 权限不足(403)`); + } else { + console.log(`❌ 删除失败: ${error.message}`); + } + } + } +} + +async function testDeleteOtherUserCourse() { + console.log('\n🗑️ 测试 6: 尝试删除他人创建的课程(应该失败)'); + + if (!otherUserToken) { + const otherEmail = `other_${Date.now()}@example.com`; + await registerUser(otherEmail, TEST_PASSWORD); + const loginResult = await login(otherEmail, TEST_PASSWORD); + otherUserToken = loginResult.token; + otherUserId = loginResult.userId; + } + + // 尝试删除第一个用户创建的课程 + try { + await axios.delete( + `${BASE_URL}/api/courses/${createdCourseId}`, + { + headers: { + Authorization: `Bearer ${otherUserToken}`, + }, + } + ); + console.log(`❌ 不应该成功删除他人课程`); + } catch (error: any) { + if (error.response?.status === 403) { + console.log(`✅ 正确拒绝删除他人课程(403 Forbidden)`); + } else { + console.log(`⚠️ 返回了其他错误: ${error.response?.status} - ${error.message}`); + } + } +} + +async function main() { + console.log('🚀 开始测试 createdBy 和 visibility 功能\n'); + console.log(`测试环境: ${BASE_URL}`); + + try { + // 1. 注册和登录测试用户 + console.log('\n👤 注册和登录测试用户...'); + await registerUser(TEST_EMAIL, TEST_PASSWORD); + const loginResult = await login(TEST_EMAIL, TEST_PASSWORD); + authToken = loginResult.token; + userId = loginResult.userId; + console.log(`✅ 登录成功,userId: ${userId}`); + + // 2. 测试创建课程 + await testCreateCourse(); + + // 3. 测试查询课程列表 + await testQueryCourses(); + + // 4. 测试修改自己创建的课程 + await testUpdateOwnCourse(); + + // 5. 测试修改他人创建的课程(应该失败) + await testUpdateOtherUserCourse(); + + // 6. 测试删除自己创建的课程 + await testDeleteOwnCourse(); + + // 7. 测试删除他人创建的课程(应该失败) + await testDeleteOtherUserCourse(); + + console.log('\n✅ 所有测试完成!'); + } catch (error: any) { + console.error('\n❌ 测试失败:', error.message); + if (error.response) { + console.error('响应数据:', JSON.stringify(error.response.data, null, 2)); + } + process.exit(1); + } +} + +main(); diff --git a/backend/scripts/test-doubao-api.ts b/backend/scripts/test-doubao-api.ts new file mode 100644 index 0000000..ddf0c7b --- /dev/null +++ b/backend/scripts/test-doubao-api.ts @@ -0,0 +1,91 @@ +/** + * 测试豆包 API 连接 + */ + +import OpenAI from 'openai'; + +const DOUBAO_API_KEY = process.env.DOUBAO_API_KEY || '79250955-70db-4f84-a3be-dada39a62b1f'; +const DOUBAO_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'; +const DOUBAO_MODEL = 'doubao-seed-1-6-lite-251015'; + +const openai = new OpenAI({ + apiKey: DOUBAO_API_KEY, + baseURL: DOUBAO_BASE_URL, +}); + +async function testDoubaoAPI() { + console.log('═══════════════════════════════════════════════════════════'); + console.log(' 🧪 测试豆包 API 连接'); + console.log('═══════════════════════════════════════════════════════════'); + console.log(''); + console.log(`API Key: ${DOUBAO_API_KEY.substring(0, 20)}...`); + console.log(`Base URL: ${DOUBAO_BASE_URL}`); + console.log(`Model: ${DOUBAO_MODEL}`); + console.log(''); + + try { + console.log('📤 发送测试请求...'); + const startTime = Date.now(); + + const response = await openai.chat.completions.create({ + model: DOUBAO_MODEL, + messages: [ + { + role: 'system', + content: '你是一个专业的AI助手。', + }, + { + role: 'user', + content: '请用一句话介绍你自己。', + }, + ], + max_tokens: 100, + temperature: 0.7, + }); + + const duration = Date.now() - startTime; + const content = response.choices[0]?.message?.content; + + console.log('✅ API 调用成功!'); + console.log(''); + console.log('📊 响应信息:'); + console.log(` 响应时间: ${duration}ms`); + console.log(` 使用 Token: ${response.usage?.total_tokens || 'N/A'}`); + console.log(` 模型: ${response.model || DOUBAO_MODEL}`); + console.log(''); + console.log('💬 AI 回复:'); + console.log(` ${content}`); + console.log(''); + console.log('═══════════════════════════════════════════════════════════'); + console.log(' ✅ 豆包 API 测试通过!'); + console.log('═══════════════════════════════════════════════════════════'); + + return true; + } catch (error: any) { + console.error('❌ API 调用失败!'); + console.error(''); + console.error('错误信息:'); + console.error(` 类型: ${error.constructor.name}`); + console.error(` 消息: ${error.message}`); + if (error.response) { + console.error(` 状态码: ${error.response.status}`); + console.error(` 响应: ${JSON.stringify(error.response.data, null, 2)}`); + } + console.error(''); + console.error('═══════════════════════════════════════════════════════════'); + console.error(' ❌ 豆包 API 测试失败!'); + console.error('═══════════════════════════════════════════════════════════'); + + return false; + } +} + +// 执行测试 +testDoubaoAPI() + .then((success) => { + process.exit(success ? 0 : 1); + }) + .catch((error) => { + console.error('未预期的错误:', error); + process.exit(1); + }); diff --git a/backend/scripts/test-experience-curve.ts b/backend/scripts/test-experience-curve.ts new file mode 100644 index 0000000..6ee7f9d --- /dev/null +++ b/backend/scripts/test-experience-curve.ts @@ -0,0 +1,69 @@ +// 测试星露谷风格经验曲线 + +const LEVEL_EXP_TABLE = [ + 0, // Lv.1 起始(占位) + 100, // Lv.1→2 所需 + 200, // Lv.2→3 所需 + 400, // Lv.3→4 所需 + 800, // Lv.4→5 所需 + 1200, // Lv.5→6 所需 + 1800, // Lv.6→7 所需 + 2500, // Lv.7→8 所需 + 3500, // Lv.8→9 所需 + 5000, // Lv.9→10 所需 +]; + +function getLevelStartExp(level: number): number { + let total = 0; + for (let i = 1; i < level && i < LEVEL_EXP_TABLE.length; i++) { + total += LEVEL_EXP_TABLE[i]; + } + return total; +} + +function getLevelExpRequirement(level: number): number { + if (level >= LEVEL_EXP_TABLE.length) { + return LEVEL_EXP_TABLE[LEVEL_EXP_TABLE.length - 1]; + } + return LEVEL_EXP_TABLE[level] || 0; +} + +function calculateLevel(experience: number): number { + let level = 1; + let totalExp = 0; + + for (let i = 1; i < LEVEL_EXP_TABLE.length; i++) { + const requirement = LEVEL_EXP_TABLE[i]; + if (totalExp + requirement > experience) { + break; + } + totalExp += requirement; + level++; + } + + if (level >= LEVEL_EXP_TABLE.length) { + const lastRequirement = LEVEL_EXP_TABLE[LEVEL_EXP_TABLE.length - 1]; + const remainingExp = experience - totalExp; + level += Math.floor(remainingExp / lastRequirement); + } + + return level; +} + +console.log('🧪 测试星露谷风格经验曲线\n'); +console.log('经验值 | 等级 | 等级起始 | 升级所需 | 下一级经验'); +console.log('------|------|---------|---------|-----------'); + +const testCases = [0, 50, 100, 200, 300, 500, 700, 1000, 1500, 2000, 3000, 5000, 10000, 15500]; + +testCases.forEach(exp => { + const lv = calculateLevel(exp); + const startExp = getLevelStartExp(lv); + const requirement = getLevelExpRequirement(lv); + const nextLevelExp = startExp + requirement; + const remaining = nextLevelExp - exp; + + console.log(`${exp.toString().padStart(6)} | Lv.${lv.toString().padStart(2)} | ${startExp.toString().padStart(7)} | ${requirement.toString().padStart(7)} | ${nextLevelExp.toString().padStart(9)} (还差 ${remaining})`); +}); + +console.log('\n✅ 测试完成!'); diff --git a/backend/scripts/test-notebook-api.sh b/backend/scripts/test-notebook-api.sh new file mode 100755 index 0000000..4898513 --- /dev/null +++ b/backend/scripts/test-notebook-api.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# 测试笔记本API,查看实际返回 + +SERVER_IP="120.55.112.195" +SERVER_USER="root" +SERVER_PASS="yangyichenYANGYICHENkaifa859" + +echo "🔍 测试笔记本API..." +echo "" + +sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$SERVER_IP << 'REMOTE_SCRIPT' +cd /var/www/wildgrowth-backend/backend + +# 获取一个测试token(从数据库) +TOKEN=$(node -e " +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const jwt = require('jsonwebtoken'); + +(async () => { + try { + const user = await prisma.user.findFirst({ + where: { id: 'def0ffdc-d078-490c-b713-6462d1027a77' }, + }); + + if (user) { + const token = jwt.sign( + { userId: user.id }, + process.env.JWT_SECRET || 'your-secret-key', + { expiresIn: '7d' } + ); + console.log(token); + } + } catch (error) { + console.error('Error:', error); + } finally { + await prisma.\$disconnect(); + } +})(); +") + +echo "📝 调用API..." +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/notebooks | python3 -m json.tool + +echo "" +echo "📊 查看最新日志..." +pm2 logs wildgrowth-api --lines 20 --nostream | grep -E "\[DEBUG\]|\[Notebook\]|console.log" | tail -10 +REMOTE_SCRIPT diff --git a/backend/scripts/test-notebook-cover.sh b/backend/scripts/test-notebook-cover.sh new file mode 100755 index 0000000..ea72a33 --- /dev/null +++ b/backend/scripts/test-notebook-cover.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# 测试笔记本封面获取逻辑 + +SERVER_IP="120.55.112.195" +SERVER_USER="root" +SERVER_PASS="yangyichenYANGYICHENkaifa859" + +sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$SERVER_IP << 'REMOTE_SCRIPT' +cd /var/www/wildgrowth-backend/backend + +node -e " +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +(async () => { + try { + const notebookId = 'b8f11787-9529-476a-9c31-717001b96cd8'; + + // 1. 查询笔记本 + const notebook = await prisma.notebook.findUnique({ + where: { id: notebookId }, + select: { id: true, title: true, coverImage: true }, + }); + console.log('1. 笔记本信息:'); + console.log(JSON.stringify(notebook, null, 2)); + + // 2. 提取课程名称 + const courseName = notebook.title.replace(/《/g, '').replace(/》/g, '').trim(); + console.log('\\n2. 提取的课程名称:', courseName); + + // 3. 查找匹配的课程 + const course = await prisma.course.findFirst({ + where: { title: courseName }, + select: { id: true, title: true, coverImage: true }, + }); + console.log('\\n3. 匹配的课程:'); + console.log(JSON.stringify(course, null, 2)); + + // 4. 模拟代码逻辑 + let coverImage = notebook.coverImage; + console.log('\\n4. 初始封面:', coverImage || 'null'); + + if (!coverImage || (coverImage && coverImage.trim() === '')) { + console.log(' 进入封面获取逻辑'); + if (course && course.coverImage) { + coverImage = course.coverImage; + console.log(' 更新封面为:', coverImage); + } + } + + console.log('\\n5. 最终封面:', coverImage || 'null'); + + } catch (error) { + console.error('错误:', error); + } finally { + await prisma.\$disconnect(); + } +})(); +" +REMOTE_SCRIPT diff --git a/backend/scripts/test-optimization.sh b/backend/scripts/test-optimization.sh new file mode 100755 index 0000000..1a365f4 --- /dev/null +++ b/backend/scripts/test-optimization.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +# AI 内容生成优化测试脚本 +# 测试队列系统、预生成、批量生成等功能 + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🧪 开始测试 AI 内容生成优化功能...${NC}\n" + +# 1. 检查 Redis 连接 +echo -e "${YELLOW}1. 检查 Redis 连接...${NC}" +if command -v redis-cli &> /dev/null; then + if redis-cli ping &> /dev/null; then + echo -e "${GREEN}✅ Redis 连接正常${NC}" + else + echo -e "${RED}❌ Redis 未运行,请启动 Redis${NC}" + exit 1 + fi +else + echo -e "${YELLOW}⚠️ redis-cli 未安装,跳过 Redis 检查${NC}" +fi + +# 2. 检查依赖 +echo -e "\n${YELLOW}2. 检查依赖...${NC}" +if [ -d "node_modules/bullmq" ]; then + echo -e "${GREEN}✅ bullmq 已安装${NC}" +else + echo -e "${RED}❌ bullmq 未安装,运行: npm install bullmq${NC}" + exit 1 +fi + +# 3. 检查 Prisma Client +echo -e "\n${YELLOW}3. 检查 Prisma Client...${NC}" +if [ -d "node_modules/@prisma/client" ]; then + echo -e "${GREEN}✅ Prisma Client 已生成${NC}" +else + echo -e "${YELLOW}⚠️ Prisma Client 未生成,运行: npm run prisma:generate${NC}" + npm run prisma:generate +fi + +# 4. 编译 TypeScript +echo -e "\n${YELLOW}4. 编译 TypeScript...${NC}" +if npm run build 2>&1 | grep -q "error TS"; then + echo -e "${RED}❌ TypeScript 编译失败${NC}" + npm run build 2>&1 | grep "error TS" | head -10 + exit 1 +else + echo -e "${GREEN}✅ TypeScript 编译成功${NC}" +fi + +# 5. 检查新增文件 +echo -e "\n${YELLOW}5. 检查新增文件...${NC}" +files=( + "src/services/queueService.ts" + "src/services/templateService.ts" + "src/workers/aiContentWorker.ts" +) + +all_exist=true +for file in "${files[@]}"; do + if [ -f "$file" ]; then + echo -e "${GREEN}✅ $file${NC}" + else + echo -e "${RED}❌ $file 不存在${NC}" + all_exist=false + fi +done + +if [ "$all_exist" = false ]; then + exit 1 +fi + +# 6. 检查数据库 Schema +echo -e "\n${YELLOW}6. 检查数据库 Schema...${NC}" +if grep -q "wordCount" prisma/schema.prisma && \ + grep -q "outlineEssence" prisma/schema.prisma && \ + grep -q "outlineFull" prisma/schema.prisma; then + echo -e "${GREEN}✅ Schema 已更新(包含新字段)${NC}" +else + echo -e "${RED}❌ Schema 未更新,缺少新字段${NC}" + exit 1 +fi + +# 7. 检查代码关键功能 +echo -e "\n${YELLOW}7. 检查代码关键功能...${NC}" + +# 检查队列服务 +if grep -q "addOutlineTask" src/services/queueService.ts; then + echo -e "${GREEN}✅ 队列服务:大纲任务添加功能${NC}" +else + echo -e "${RED}❌ 队列服务缺少 addOutlineTask${NC}" + exit 1 +fi + +# 检查降级策略 +if grep -q "templateService" src/services/aiService.ts; then + echo -e "${GREEN}✅ AI 服务:多级降级策略${NC}" +else + echo -e "${RED}❌ AI 服务缺少降级策略${NC}" + exit 1 +fi + +# 检查预生成逻辑 +if grep -q "outlineEssence\|outlineFull" src/controllers/aiContentController.ts; then + echo -e "${GREEN}✅ 控制器:预生成逻辑${NC}" +else + echo -e "${RED}❌ 控制器缺少预生成逻辑${NC}" + exit 1 +fi + +# 检查批量生成 +if grep -q "Promise.allSettled" src/controllers/aiContentController.ts | grep -q "nodeResults"; then + echo -e "${GREEN}✅ 控制器:批量节点生成${NC}" +else + echo -e "${YELLOW}⚠️ 批量生成逻辑需要验证${NC}" +fi + +# 8. 检查前端逻辑层 +echo -e "\n${YELLOW}8. 检查前端逻辑层...${NC}" +if [ -f "../ios/WildGrowth/WildGrowth/CourseServiceExtension.swift" ]; then + if grep -q "selectGenerationMode" ../ios/WildGrowth/WildGrowth/CourseServiceExtension.swift && \ + grep -q "getTaskStatus" ../ios/WildGrowth/WildGrowth/CourseServiceExtension.swift; then + echo -e "${GREEN}✅ 前端逻辑层:新增接口方法${NC}" + else + echo -e "${RED}❌ 前端逻辑层缺少新方法${NC}" + exit 1 + fi + + if grep -q "GenerationMode" ../ios/WildGrowth/WildGrowth/CourseModels.swift; then + echo -e "${GREEN}✅ 前端模型:GenerationMode 枚举${NC}" + else + echo -e "${RED}❌ 前端模型缺少 GenerationMode${NC}" + exit 1 + fi +else + echo -e "${YELLOW}⚠️ 前端文件路径可能不同,跳过检查${NC}" +fi + +echo -e "\n${GREEN}✅ 所有检查通过!${NC}" +echo -e "\n${YELLOW}📝 下一步:${NC}" +echo -e "1. 运行数据库迁移: ${GREEN}npm run prisma:migrate${NC}" +echo -e "2. 启动服务器测试: ${GREEN}npm run dev${NC}" +echo -e "3. 实施 UI 层改造(见 FRONTEND_UI_REQUIREMENTS.md)" diff --git a/backend/scripts/test-simple.sh b/backend/scripts/test-simple.sh new file mode 100755 index 0000000..5c45dd9 --- /dev/null +++ b/backend/scripts/test-simple.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# 简单的测试脚本 - 只测试创建课程和查询状态 + +BASE_URL="https://api.muststudy.xin" +EMAIL="test@example.com" +PASSWORD="test123456" + +echo "🧪 简单测试:创建课程并查询状态" +echo "" + +# 1. 登录 +echo "1. 登录..." +TOKEN=$(curl -s -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | \ + grep -o '"token":"[^"]*' | cut -d'"' -f4) + +if [ -z "$TOKEN" ]; then + echo "注册新用户..." + curl -s -X POST "$BASE_URL/api/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"username\":\"测试用户\"}" > /dev/null + + TOKEN=$(curl -s -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | \ + grep -o '"token":"[^"]*' | cut -d'"' -f4) +fi + +if [ -z "$TOKEN" ]; then + echo "❌ 登录失败" + exit 1 +fi + +echo "✅ 登录成功" +echo "" + +# 2. 创建课程 +echo "2. 创建课程..." +RESPONSE=$(curl -s -X POST "$BASE_URL/api/ai/content/upload" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "content": "这是一个测试内容。社交是每个人都需要掌握的重要技能。", + "style": "essence" + }') + +COURSE_ID=$(echo "$RESPONSE" | grep -o '"courseId":"[^"]*' | cut -d'"' -f4) +TASK_ID=$(echo "$RESPONSE" | grep -o '"taskId":"[^"]*' | cut -d'"' -f4) + +if [ -z "$COURSE_ID" ]; then + echo "❌ 创建课程失败" + echo "$RESPONSE" + exit 1 +fi + +echo "✅ 课程创建成功" +echo " Course ID: $COURSE_ID" +echo " Task ID: $TASK_ID" +echo "" + +# 3. 查询状态(轮询5次) +echo "3. 查询生成状态..." +for i in {1..5}; do + sleep 3 + STATUS=$(curl -s -X GET "$BASE_URL/api/my-courses" \ + -H "Authorization: Bearer $TOKEN" | \ + grep -o "\"id\":\"$COURSE_ID\"[^}]*" | \ + grep -o '"generation_progress":[0-9.]*' | cut -d':' -f2) + + if [ -n "$STATUS" ]; then + PROGRESS=$(echo "$STATUS * 100" | bc) + echo " 进度: ${PROGRESS}%" + else + echo " 查询中..." + fi +done + +echo "" +echo "✅ 测试完成!" diff --git a/backend/scripts/test-structure-chunking.ts b/backend/scripts/test-structure-chunking.ts new file mode 100644 index 0000000..a93b815 --- /dev/null +++ b/backend/scripts/test-structure-chunking.ts @@ -0,0 +1,107 @@ +/** + * 测试脚本:按章分块服务 + * + * 用法: + * npx ts-node scripts/test-structure-chunking.ts [文件路径] + */ + +import fs from 'fs'; +import path from 'path'; +import { structureChunkingService } from '../src/services/structureChunkingService'; + +// 测试文本:多章节 +const TEST_TEXT_CHAPTERS = ` +第一章 绪论 + +人工智能(Artificial Intelligence,AI)是计算机科学的一个分支, +它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。 + +人工智能的发展可以追溯到20世纪50年代。1956年,在达特茅斯会议上, +"人工智能"这一术语首次被提出。 + +第二章 机器学习基础 + +监督学习是机器学习的一种方法,其中训练数据既包含输入数据,也包含期望的输出结果。 +模型通过学习输入和输出之间的映射关系来进行预测。 + +无监督学习是另一种机器学习方法,训练数据只包含输入数据,没有对应的输出标签。 +模型需要自己发现数据中的结构和模式。 + +第 3 章 深度学习 + +深度学习是机器学习的一个子领域,使用多层神经网络来学习数据的层次化表示。 +卷积神经网络(CNN)特别适合处理图像数据,而循环神经网络(RNN)则适合处理序列数据。 +`; + +// 测试文本:无章节结构 +const TEST_TEXT_NO_STRUCTURE = ` +这是一段没有明确结构的文本。 + +它只是一些段落的集合,没有章节标题,也没有编号。 + +用户可能会上传这样的文本,我们需要能够处理它。 +`; + +async function main() { + console.log('='.repeat(60)); + console.log('按章分块服务测试'); + console.log('='.repeat(60)); + console.log(); + + const filePath = process.argv[2]; + let testTexts: { name: string; text: string }[] = []; + + if (filePath) { + const absolutePath = path.resolve(filePath); + if (!fs.existsSync(absolutePath)) { + console.error(`错误:文件不存在 - ${absolutePath}`); + process.exit(1); + } + const fileContent = fs.readFileSync(absolutePath, 'utf-8'); + testTexts.push({ name: `文件: ${path.basename(filePath)}`, text: fileContent }); + } else { + testTexts = [ + { name: '多章节', text: TEST_TEXT_CHAPTERS }, + { name: '无结构', text: TEST_TEXT_NO_STRUCTURE }, + ]; + } + + for (const { name, text } of testTexts) { + console.log('-'.repeat(60)); + console.log(`测试: ${name}`); + console.log(`输入长度: ${text.length} 字符`); + console.log('-'.repeat(60)); + + const startTime = Date.now(); + const result = await structureChunkingService.parseAsync(text); + const duration = Date.now() - startTime; + + console.log(`成功: ${result.success}`); + console.log(`模式: ${result.pattern || '无'}`); + console.log(`分块数: ${result.chunks.length}`); + console.log(`字符数: ${result.totalCharacters}`); + console.log(`耗时: ${duration}ms`); + console.log(); + + if (result.chunks.length > 0) { + console.log('分块详情:'); + for (const chunk of result.chunks) { + const preview = chunk.content.substring(0, 50).replace(/\n/g, ' '); + console.log(` [${chunk.order + 1}] ${chunk.title}`); + console.log(` ${preview}${chunk.content.length > 50 ? '...' : ''} (${chunk.content.length} 字符)`); + } + } else { + console.log('未检测到章级结构'); + } + console.log(); + } + + console.log('='.repeat(60)); + console.log('测试完成'); + console.log('='.repeat(60)); +} + +main().catch((err) => { + console.error('测试失败:', err); + process.exit(1); +}); diff --git a/backend/scripts/update-course-cover-production.sh b/backend/scripts/update-course-cover-production.sh new file mode 100755 index 0000000..5ae2006 --- /dev/null +++ b/backend/scripts/update-course-cover-production.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 在生产服务器上更新课程封面 + +SERVER_IP="120.55.112.195" +SERVER_USER="root" +SERVER_PASS="yangyichenYANGYICHENkaifa859" + +sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$SERVER_IP << 'REMOTE_SCRIPT' +cd /var/www/wildgrowth-backend/backend + +# 更新课程封面 +npx ts-node -e " +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +(async () => { + try { + const courseId = 'course_vertical_001'; + const coverImage = 'images/a0c457c5-4a4c-4cd1-9d17-641e400ab875.PNG'; + + console.log('🚀 开始更新课程封面...'); + console.log('课程ID:', courseId); + console.log('封面:', coverImage); + + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { id: true, title: true, coverImage: true }, + }); + + if (!course) { + console.error('❌ 课程不存在'); + process.exit(1); + } + + console.log('当前封面:', course.coverImage || 'null'); + + const updated = await prisma.course.update({ + where: { id: courseId }, + data: { coverImage }, + select: { id: true, title: true, coverImage: true }, + }); + + console.log('✅ 更新成功!'); + console.log('新封面:', updated.coverImage); + } catch (error) { + console.error('❌ 更新失败:', error); + process.exit(1); + } finally { + await prisma.\$disconnect(); + } +})(); +" + +# 重启PM2服务 +pm2 restart wildgrowth-api + +echo "✅ 完成!" +REMOTE_SCRIPT diff --git a/backend/scripts/update-course-cover.ts b/backend/scripts/update-course-cover.ts new file mode 100644 index 0000000..9395402 --- /dev/null +++ b/backend/scripts/update-course-cover.ts @@ -0,0 +1,62 @@ +/** + * 脚本:更新课程的封面图片 + * + * 使用方法: + * npx ts-node scripts/update-course-cover.ts + */ + +import prisma from '../src/utils/prisma'; + +async function updateCourseCover() { + try { + console.log('🚀 开始更新课程封面...\n'); + + // 更新"高效沟通的艺术"课程的封面 + const courseId = 'course_vertical_001'; + const coverImage = 'images/a0c457c5-4a4c-4cd1-9d17-641e400ab875.PNG'; + + console.log(`📖 更新课程: ${courseId}`); + console.log(` - 封面: ${coverImage}`); + + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { id: true, title: true, coverImage: true }, + }); + + if (!course) { + console.error(`❌ 课程不存在: ${courseId}`); + return; + } + + console.log(` - 当前标题: ${course.title}`); + console.log(` - 当前封面: ${course.coverImage || 'null'}`); + + // 更新封面 + const updatedCourse = await prisma.course.update({ + where: { id: courseId }, + data: { coverImage }, + select: { id: true, title: true, coverImage: true }, + }); + + console.log(`\n✅ 更新成功!`); + console.log(` - 课程: ${updatedCourse.title}`); + console.log(` - 新封面: ${updatedCourse.coverImage}`); + + } catch (error) { + console.error('❌ 更新失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行更新 +updateCourseCover() + .then(() => { + console.log('\n✅ 脚本执行完成'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ 脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/update-notebook-covers.ts b/backend/scripts/update-notebook-covers.ts new file mode 100644 index 0000000..888aaa2 --- /dev/null +++ b/backend/scripts/update-notebook-covers.ts @@ -0,0 +1,164 @@ +/** + * 脚本:为所有笔记本自动更新封面(从关联笔记的课程中获取) + * ✅ 新增:支持通过课程名称匹配(处理历史数据问题) + * + * 使用方法: + * npx ts-node scripts/update-notebook-covers.ts + */ + +import prisma from '../src/utils/prisma'; + +// ✅ 辅助函数:从笔记本标题中提取课程名称(移除书名号) +function extractCourseNameFromTitle(notebookTitle: string): string { + // 移除书名号《》和多余的空格 + return notebookTitle + .replace(/《/g, '') + .replace(/》/g, '') + .trim(); +} + +async function updateNotebookCovers() { + try { + console.log('🚀 开始更新笔记本封面...\n'); + + // 获取所有封面为空的笔记本 + const notebooks = await prisma.notebook.findMany({ + where: { + OR: [ + { coverImage: null }, + { coverImage: '' }, + ], + }, + include: { + _count: { + select: { notes: true }, + }, + }, + }); + + console.log(`📚 找到 ${notebooks.length} 个需要更新封面的笔记本\n`); + + let updatedCount = 0; + let skippedCount = 0; + + for (const notebook of notebooks) { + console.log(`📖 处理笔记本: ${notebook.title} (ID: ${notebook.id})`); + console.log(` - 当前封面: ${notebook.coverImage || 'null'}`); + console.log(` - 笔记数量: ${notebook._count.notes}`); + + let coverImage: string | null = null; + let source = ''; + + // 方法1:查找该笔记本的第一个有课程关联的笔记 + const firstNote = await prisma.note.findFirst({ + where: { + notebookId: notebook.id, + courseId: { not: null }, + }, + orderBy: { createdAt: 'asc' }, + include: { + course: { + select: { + id: true, + title: true, + coverImage: true, + }, + }, + }, + }); + + if (firstNote?.course?.coverImage) { + // ✅ 方法1成功:从笔记关联的课程获取封面 + coverImage = firstNote.course.coverImage; + source = `笔记关联的课程: ${firstNote.course.title} (ID: ${firstNote.course.id})`; + } else if (firstNote?.courseId) { + // 笔记有 courseId,但课程没有封面(单独查询) + const course = await prisma.course.findUnique({ + where: { id: firstNote.courseId }, + select: { coverImage: true, title: true }, + }); + if (course?.coverImage) { + coverImage = course.coverImage; + source = `课程查询: ${course.title || firstNote.courseId}`; + } + } + + // ✅ 方法2:如果方法1失败,尝试通过课程名称匹配(处理历史数据) + if (!coverImage && notebook._count.notes > 0) { + const courseName = extractCourseNameFromTitle(notebook.title); + console.log(` 🔍 尝试通过课程名称匹配: "${courseName}"`); + + // 查找匹配的课程(标题包含课程名称) + const matchingCourse = await prisma.course.findFirst({ + where: { + title: { + contains: courseName, + }, + coverImage: { not: null }, + }, + select: { + id: true, + title: true, + coverImage: true, + }, + }); + + if (matchingCourse?.coverImage) { + coverImage = matchingCourse.coverImage; + source = `课程名称匹配: ${matchingCourse.title} (ID: ${matchingCourse.id})`; + console.log(` ✅ 找到匹配的课程: ${matchingCourse.title}`); + } + } + + // 更新笔记本封面 + if (coverImage) { + await prisma.notebook.update({ + where: { id: notebook.id }, + data: { coverImage }, + }); + + console.log(` ✅ 成功更新封面: ${coverImage}`); + console.log(` 📘 来源: ${source}\n`); + updatedCount++; + } else { + // 无法获取封面 + const noteCount = await prisma.note.count({ + where: { notebookId: notebook.id }, + }); + const noteWithCourseId = await prisma.note.count({ + where: { + notebookId: notebook.id, + courseId: { not: null }, + }, + }); + if (noteCount > 0) { + console.log(` ⚠️ 有 ${noteCount} 条笔记,其中 ${noteWithCourseId} 条有 courseId,但无法获取封面\n`); + } else { + console.log(` ⚠️ 没有笔记\n`); + } + skippedCount++; + } + } + + console.log('\n📊 更新完成:'); + console.log(` ✅ 成功更新: ${updatedCount} 个`); + console.log(` ⚠️ 跳过: ${skippedCount} 个`); + + } catch (error) { + console.error('❌ 更新失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行更新 +updateNotebookCovers() + .then(() => { + console.log('\n✅ 脚本执行完成'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ 脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/update-prompt-templates.ts b/backend/scripts/update-prompt-templates.ts new file mode 100644 index 0000000..bb30df4 --- /dev/null +++ b/backend/scripts/update-prompt-templates.ts @@ -0,0 +1,77 @@ +/** + * 更新数据库中的 User Prompt Template + * 将旧版本(包含"要求"和"输出格式")更新为新版本(只包含任务描述和变量) + * + * 使用方法: + * cd backend + * npx ts-node scripts/update-prompt-templates.ts + */ + +import prisma from '../src/utils/prisma'; +import { promptConfigService } from '../src/services/promptConfigService'; + +async function updatePromptTemplates() { + try { + console.log('🔄 开始更新数据库中的 User Prompt Template...\n'); + + const types: ('summary' | 'outline' | 'content')[] = ['summary', 'outline', 'content']; + + for (const promptType of types) { + console.log(`\n📝 处理类型: ${promptType}`); + + // 获取数据库中的配置 + const config = await prisma.aIPromptConfig.findUnique({ + where: { promptType }, + }); + + if (!config) { + console.log(` ⚠️ 数据库中没有 ${promptType} 类型的配置,跳过`); + continue; + } + + // 获取新的默认配置 + const defaultConfig = promptConfigService.getPromptConfig(promptType); + const newUserPromptTemplate = defaultConfig.userPromptTemplate; + + // 检查是否需要更新 + const oldUserPromptTemplate = config.userPromptTemplate || ''; + + // 检查是否包含"要求"或"输出格式"(旧版本的特征) + const hasOldFormat = oldUserPromptTemplate.includes('要求:') || + oldUserPromptTemplate.includes('输出格式'); + + if (!hasOldFormat && oldUserPromptTemplate.trim() === newUserPromptTemplate.trim()) { + console.log(` ✅ ${promptType} 已经是新版本,无需更新`); + continue; + } + + console.log(` 📋 旧版本 User Prompt Template:`); + console.log(` ${oldUserPromptTemplate.substring(0, 100)}...`); + console.log(` 📋 新版本 User Prompt Template:`); + console.log(` ${newUserPromptTemplate}`); + + // 更新数据库 + await prisma.aIPromptConfig.update({ + where: { promptType }, + data: { + userPromptTemplate: newUserPromptTemplate, + version: config.version + 1, + }, + }); + + console.log(` ✅ ${promptType} 已更新到新版本`); + } + + console.log('\n✅ 所有配置更新完成!'); + console.log('\n💡 提示:服务器会自动重新加载配置到内存缓存'); + + } catch (error: any) { + console.error('❌ 更新失败:', error.message); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// 执行更新 +updatePromptTemplates(); diff --git a/backend/scripts/verify-call-records-api.js b/backend/scripts/verify-call-records-api.js new file mode 100644 index 0000000..5acec9c --- /dev/null +++ b/backend/scripts/verify-call-records-api.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * 调用记录 API 可用性验证(部署后执行) + * 用法: node scripts/verify-call-records-api.js [BASE_URL] + * 默认: http://localhost:3000 + * 成功: exit 0,失败: exit 1 + */ + +const baseUrl = process.argv[2] || 'http://localhost:3000'; +const url = `${baseUrl.replace(/\/$/, '')}/api/admin/generation-tasks?page=1&pageSize=5`; + +function main() { + const http = require(baseUrl.startsWith('https') ? 'https' : 'http'); + const u = new URL(url); + const options = { hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + u.search, method: 'GET' }; + + const req = http.request(options, (res) => { + let body = ''; + res.on('data', (ch) => { body += ch; }); + res.on('end', () => { + if (res.statusCode !== 200) { + console.error('FAIL: status', res.statusCode, body.slice(0, 200)); + process.exit(1); + } + try { + const data = JSON.parse(body); + if (!data.success || !Array.isArray(data.data?.list)) { + console.error('FAIL: response shape', JSON.stringify(data).slice(0, 300)); + process.exit(1); + } + if (!data.data.pagination || typeof data.data.pagination.total !== 'number') { + console.error('FAIL: missing pagination'); + process.exit(1); + } + console.log('OK: 调用记录 API 可用', data.data.list.length, '条本页, 共', data.data.pagination.total, '条'); + process.exit(0); + } catch (e) { + console.error('FAIL: parse', e.message); + process.exit(1); + } + }); + }); + req.on('error', (e) => { + console.error('FAIL: request', e.message); + process.exit(1); + }); + req.setTimeout(10000, () => { req.destroy(); process.exit(1); }); + req.end(); +} + +main(); diff --git a/backend/scripts/xhs-cover-playground.ts b/backend/scripts/xhs-cover-playground.ts new file mode 100644 index 0000000..e63f07d --- /dev/null +++ b/backend/scripts/xhs-cover-playground.ts @@ -0,0 +1,179 @@ +/** + * 小红书封面 Playground + * 用 node-canvas 画一张「截图风格」的封面,验证能否做出来。 + * + * 运行:在 backend 目录下执行 + * npm run playground:xhs-cover + * 或 + * npx ts-node scripts/xhs-cover-playground.ts + * + * 输出:backend/playground-xhs-cover.png(用系统看图打开即可) + */ + +import { createCanvas, CanvasRenderingContext2D } from 'canvas'; +import fs from 'fs/promises'; +import path from 'path'; + +// 小红书常用比例 3:4,先用小尺寸快速验证(可改为 1080×1440 出高清) +const WIDTH = 540; +const HEIGHT = 720; + +function wrapText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, + fontSize: number, + maxLines = 6 +): string[] { + const lines: string[] = []; + let currentLine = ''; + for (const char of text.split('')) { + const testLine = currentLine + char; + const metrics = ctx.measureText(testLine); + if (metrics.width > maxWidth && currentLine.length > 0) { + lines.push(currentLine); + currentLine = char; + if (lines.length >= maxLines) break; + } else { + currentLine = testLine; + } + } + if (currentLine.length > 0) lines.push(currentLine); + return lines; +} + +async function main() { + const canvas = createCanvas(WIDTH, HEIGHT); + const ctx = canvas.getContext('2d'); + + // 1. 浅黄背景 + ctx.fillStyle = '#FFFDE7'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + // 2. 网格(细线) + const gridStep = 24; + ctx.strokeStyle = 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 0.5; + for (let x = 0; x <= WIDTH; x += gridStep) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, HEIGHT); + ctx.stroke(); + } + for (let y = 0; y <= HEIGHT; y += gridStep) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(WIDTH, y); + ctx.stroke(); + } + + // 3. 白色圆角卡片(居中,留边距) + const padding = 48; + const cardX = padding; + const cardY = 120; + const cardW = WIDTH - padding * 2; + const cardH = 380; + const radius = 20; + + // 圆角矩形(兼容无 roundRect 的环境) + function drawRoundRect(x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } + + ctx.fillStyle = '#FFFFFF'; + drawRoundRect(cardX, cardY, cardW, cardH, radius); + ctx.fill(); + + ctx.strokeStyle = 'rgba(0,0,0,0.04)'; + ctx.lineWidth = 0.5; + drawRoundRect(cardX, cardY, cardW, cardH, radius); + ctx.stroke(); + + // 4. 卡片顶部黄色小条(像截图里的 accent tab) + const tabW = 60; + const tabH = 12; + const tabX = cardX + (cardW - tabW) / 2; + const tabY = cardY - tabH / 2; + ctx.fillStyle = '#FFD93D'; + drawRoundRect(tabX, tabY, tabW, tabH, 4); + ctx.fill(); + + // 5. 卡片内多行文字(加粗、黑色) + const textX = cardX + 32; + const textY = cardY + 44; + const lineHeight = 38; + const maxTextWidth = cardW - 64; + + // 尽量用系统里有的中文字体(Mac 有 PingFang SC) + ctx.font = 'bold 26px "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif'; + ctx.fillStyle = '#000000'; + ctx.textAlign = 'left'; + + const lines = [ + '谁懂!', + '人生最好的事情', + '不过是:', + '拥有好朋友和', + '幸福生活!', + ]; + + let y = textY; + for (const line of lines) { + const wrapped = wrapText(ctx, line, maxTextWidth, 26, 1); + for (const seg of wrapped) { + ctx.fillText(seg, textX, y); + y += lineHeight; + } + } + + // 6. 卡片左下角装饰虚线(几条短横线) + const dashY = cardY + cardH - 40; + ctx.strokeStyle = '#FFD93D'; + ctx.lineWidth = 2; + ctx.setLineDash([6, 6]); + for (let i = 0; i < 4; i++) { + ctx.beginPath(); + ctx.moveTo(textX + i * 36, dashY); + ctx.lineTo(textX + i * 36 + 24, dashY); + ctx.stroke(); + } + ctx.setLineDash([]); + + // 7. 右下角「贴纸」占位(画一个圆 + 文字,模拟 emoji 位;真 emoji 可后续用图片贴) + const stickerX = cardX + cardW - 72; + const stickerY = cardY + cardH - 72; + ctx.fillStyle = '#FFF3CD'; + ctx.beginPath(); + ctx.arc(stickerX, stickerY, 32, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.08)'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.font = '20px "PingFang SC", sans-serif'; + ctx.fillStyle = '#333'; + ctx.textAlign = 'center'; + ctx.fillText('贴纸', stickerX, stickerY + 6); + + // 8. 导出 PNG + const outPath = path.join(process.cwd(), 'playground-xhs-cover.png'); + const buffer = canvas.toBuffer('image/png'); + await fs.writeFile(outPath, buffer); + + console.log('✅ 已生成:', outPath); + console.log(' 用系统看图/预览打开即可查看效果。'); +} + +main().catch((e) => { + console.error('生成失败:', e); + process.exit(1); +}); diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts new file mode 100644 index 0000000..1e175bb --- /dev/null +++ b/backend/src/__tests__/setup.ts @@ -0,0 +1,12 @@ +/** + * Jest 测试环境设置 + */ + +// 设置测试环境变量 +process.env.NODE_ENV = 'test'; +process.env.DATABASE_URL = process.env.TEST_DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/wildgrowth_test'; +process.env.JWT_SECRET = 'test-secret-key'; +process.env.DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || 'test-key'; + +// 增加超时时间(用于AI和向量化测试) +jest.setTimeout(60000); // 60秒 diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts new file mode 100644 index 0000000..fb173f1 --- /dev/null +++ b/backend/src/config/index.ts @@ -0,0 +1,13 @@ +/** + * 应用配置管理 + */ +export const config = { + aliyun: { + bucket: process.env.OSS_BUCKET || 'upolar', + accessKeyId: process.env.OSS_ACCESS_KEY_ID || 'LTAI5tEp3jEPLT6NiMXwcsuX', + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || 'd3UFsrtfNEPAKHMfXnSzB3raJIkI41', + region: process.env.OSS_REGION || 'oss-cn-shanghai', + endpoint: process.env.OSS_ENDPOINT || 'https://oss-cn-shanghai.aliyuncs.com', + domain: process.env.OSS_DOMAIN || 'https://upolar.oss-cn-shanghai.aliyuncs.com', + }, +}; diff --git a/backend/src/constants.ts b/backend/src/constants.ts new file mode 100644 index 0000000..5f8991e --- /dev/null +++ b/backend/src/constants.ts @@ -0,0 +1,6 @@ +/** + * 系统常量定义 + */ + +// 系统用户 ID(用于系统笔记) +export const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000'; diff --git a/backend/src/controllers/adminCallRecordsController.ts b/backend/src/controllers/adminCallRecordsController.ts new file mode 100644 index 0000000..36a6758 --- /dev/null +++ b/backend/src/controllers/adminCallRecordsController.ts @@ -0,0 +1,233 @@ +/** + * 管理后台 - 调用记录(AI 课程生成任务列表) + * 提示词展示:仅用任务表里当时写入的 prompt_sent,不事后拼 + */ + +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; + +/** + * GET /api/admin/generation-tasks + * 查询:page, pageSize + * 返回:任务ID、课程ID、课程名称、创建时间、更新时间、状态、进度、错误信息、大纲(JSON)、来源摘要 + */ +export const listGenerationTasks = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const page = Math.max(1, parseInt(String(req.query.page), 10) || 1); + const pageSize = Math.min( + MAX_PAGE_SIZE, + Math.max(1, parseInt(String(req.query.pageSize), 10) || DEFAULT_PAGE_SIZE) + ); + const skip = (page - 1) * pageSize; + + const [tasks, total] = await Promise.all([ + prisma.courseGenerationTask.findMany({ + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + course: { + select: { id: true, title: true, createdAt: true, parentCourseId: true, accumulatedSummary: true }, + }, + }, + }), + prisma.courseGenerationTask.count(), + ]); + + const list = tasks.map((t) => ({ + taskId: t.id, + courseId: t.courseId, + courseTitle: t.course?.title ?? '', + courseCreatedAt: t.course?.createdAt?.toISOString() ?? null, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + status: t.status, + progress: t.progress, + errorMessage: t.errorMessage ?? null, + sourceType: t.sourceType ?? null, + persona: t.persona ?? null, + outline: t.outline ?? null, + sourceTextExcerpt: + t.sourceText && t.sourceText.length > 200 + ? t.sourceText.slice(0, 200) + '…' + : t.sourceText ?? null, + parentCourseId: t.course?.parentCourseId ?? null, + accumulatedSummary: t.course?.accumulatedSummary ?? null, + })); + + res.json({ + success: true, + data: { + list, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/admin/generation-tasks/:taskId + * 单条任务详情:完整创建过程、输入给 AI 的全文、AI 返回内容(不参与创建流程,只读) + */ +export const getGenerationTaskDetail = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const taskId = String(req.params.taskId || '').trim(); + if (!taskId) { + return res.status(400).json({ success: false, error: { message: '缺少 taskId' } }); + } + + const [task, aiCallLogs] = await Promise.all([ + prisma.courseGenerationTask.findUnique({ + where: { id: taskId }, + include: { + course: { + select: { id: true, title: true, createdAt: true, parentCourseId: true, accumulatedSummary: true }, + }, + }, + }), + // 获取该任务的 AI 调用日志(按时间倒序,最多取 100 条) + prisma.bookAiCallLog.findMany({ + where: { taskId }, + orderBy: { createdAt: 'desc' }, + take: 100, + select: { + id: true, + chunkIndex: true, + status: true, + responsePreview: true, + responseFull: true, + errorMessage: true, + durationMs: true, + createdAt: true, + }, + }), + ]); + + if (!task) { + return res.status(404).json({ success: false, error: { message: '任务不存在' } }); + } + + // 如果 outline 为空但有 AI 调用日志,从日志中获取原始返回内容 + let aiRawResponse: string | null = null; + if (!task.outline && aiCallLogs.length > 0) { + // 优先取最新一条的 responseFull + const latestLog = aiCallLogs[0]; + aiRawResponse = latestLog.responseFull || latestLog.responsePreview || null; + } + + res.json({ + success: true, + data: { + taskId: task.id, + courseId: task.courseId, + courseTitle: task.course?.title ?? '', + courseCreatedAt: task.course?.createdAt?.toISOString() ?? null, + parentCourseId: task.course?.parentCourseId ?? null, + accumulatedSummary: task.course?.accumulatedSummary ?? null, + createdAt: task.createdAt.toISOString(), + updatedAt: task.updatedAt.toISOString(), + status: task.status, + progress: task.progress, + currentStep: task.currentStep ?? null, + errorMessage: task.errorMessage ?? null, + sourceType: task.sourceType ?? null, + persona: task.persona ?? null, + modelId: task.modelId ?? null, + prompt: task.promptSent ?? null, + sourceText: task.sourceText ?? null, + outline: task.outline ?? null, + // 新增:AI 原始返回内容(用于调试失败任务) + aiRawResponse, + // 新增:AI 调用日志摘要 + aiCallLogs: aiCallLogs.map((log) => ({ + id: log.id, + chunkIndex: log.chunkIndex, + status: log.status, + errorMessage: log.errorMessage, + durationMs: log.durationMs, + createdAt: log.createdAt.toISOString(), + hasResponse: !!(log.responseFull || log.responsePreview), + })), + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/admin/ai-call-logs/:logId + * 获取单条 AI 调用日志的完整详情(包括原始返回内容) + */ +export const getAiCallLogDetail = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const logId = String(req.params.logId || '').trim(); + if (!logId) { + return res.status(400).json({ success: false, error: { message: '缺少 logId' } }); + } + + const log = await prisma.bookAiCallLog.findUnique({ + where: { id: logId }, + select: { + id: true, + taskId: true, + courseId: true, + chunkIndex: true, + status: true, + promptPreview: true, + promptFull: true, + responsePreview: true, + responseFull: true, + errorMessage: true, + durationMs: true, + createdAt: true, + }, + }); + + if (!log) { + return res.status(404).json({ success: false, error: { message: 'AI 调用日志不存在' } }); + } + + res.json({ + success: true, + data: { + id: log.id, + taskId: log.taskId, + courseId: log.courseId, + chunkIndex: log.chunkIndex, + status: log.status, + promptPreview: log.promptPreview, + promptFull: log.promptFull, + responsePreview: log.responsePreview, + responseFull: log.responseFull, + errorMessage: log.errorMessage, + durationMs: log.durationMs, + createdAt: log.createdAt.toISOString(), + }, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/aiCourseController.ts b/backend/src/controllers/aiCourseController.ts new file mode 100644 index 0000000..96d3a09 --- /dev/null +++ b/backend/src/controllers/aiCourseController.ts @@ -0,0 +1,288 @@ +/** + * AI 课程生成控制器 + */ + +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { CustomError } from '../middleware/errorHandler'; +import { taskService } from '../services'; +import { courseGenerationService } from '../services/courseGenerationService'; +import { logger } from '../utils/logger'; +import { getStringParam } from '../utils/requestHelpers'; +import { validateSourceText, validateSourceType, validatePersona } from '../utils/validation'; +import prisma from '../utils/prisma'; + +/** + * 统一创建课程接口(支持 sourceType 和 persona) + * POST /api/ai/courses/create + */ +export const createCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { sourceText, sourceType, persona, visibility, saveAsDraft, parentCourseId } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // ✅ 优化:使用统一的参数验证工具函数 + const validatedSourceText = validateSourceText(sourceText); + validateSourceType(sourceType); + validatePersona(persona); + + // 可选参数:后台 AI 创建时传 saveAsDraft=true、visibility;App 不传,行为不变 + const options: { saveAsDraft?: boolean; visibility?: 'public' | 'private'; parentCourseId?: string } = + saveAsDraft === true + ? { + saveAsDraft: true as const, + visibility: visibility === 'public' ? ('public' as const) : ('private' as const), + } + : {}; + // 续旧课:传 parentCourseId + if (parentCourseId && typeof parentCourseId === 'string') { + options.parentCourseId = parentCourseId; + } + + // 创建任务 + const { taskId, courseId } = await taskService.createTask( + userId, + validatedSourceText, + sourceType || undefined, + persona || undefined, + Object.keys(options).length > 0 ? options : undefined + ); + + // 启动异步生成 + courseGenerationService.processCourseGeneration(taskId).catch(async (error) => { + logger.error(`[aiCourseController] 课程生成失败: taskId=${taskId}`, error); + // ✅ 修复问题1:异步处理失败时更新任务状态 + try { + await taskService.updateStatus( + taskId, + 'failed', + 0, + undefined, + error?.message || '课程生成失败' + ); + } catch (updateError: any) { + logger.error(`[aiCourseController] 更新任务状态失败: taskId=${taskId}`, updateError); + } + }); + + res.json({ + success: true, + data: { + taskId, + courseId, + status: 'content_generating', + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 提取课程内容(用于续旧课) + * POST /api/ai/courses/:courseId/extract + */ +export const extractCourseContent = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const courseId = getStringParam(req.params.courseId); + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证课程所有权或加入状态 + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + include: { + course: { + include: { + nodes: { + orderBy: { + orderIndex: 'asc', + }, + include: { + slides: { + orderBy: { + orderIndex: 'asc', + }, + }, + }, + }, + }, + }, + }, + }); + + if (!userCourse) { + throw new CustomError('无权访问此课程', 403); + } + + // 提取课程内容:将所有节点的文本内容合并 + const extractedTexts: string[] = []; + + for (const node of userCourse.course.nodes) { + // 提取节点标题 + extractedTexts.push(`# ${node.title}`); + if (node.subtitle) { + extractedTexts.push(`## ${node.subtitle}`); + } + + // 提取幻灯片内容 + for (const slide of node.slides) { + if (slide.content && typeof slide.content === 'object') { + const content = slide.content as any; + // 提取文本内容(根据不同的 slide_type) + if (content.text) { + extractedTexts.push(content.text); + } + if (content.title) { + extractedTexts.push(content.title); + } + if (content.body) { + extractedTexts.push(content.body); + } + } + } + } + + const extractedText = extractedTexts.join('\n\n'); + + res.json({ + success: true, + data: { + text: extractedText, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取热门主题 + * GET /api/ai/topics/popular + */ +export const getPopularTopics = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // ✅ 返回预设的热门主题列表(最多10个,只少不多) + const topics = [ + '产品思维', + '数据分析', + '用户增长', + '内容创作', + '时间管理', + '沟通技巧', + '领导力', + '创业思维', + '投资理财', + '心理学', + ]; + + res.json({ + success: true, + data: { + topics, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取思考流文本 + * GET /api/ai/thinking-flow + */ +export const getThinkingFlow = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // ✅ 返回预设的思考流文本(后续可以根据任务状态动态生成) + const thinkingFlowText = `把用户要的个性化学习内容彻底理明白,不能有半点含糊,用户要的不是那种随便拼凑的通用资料,是真正适配他学习节奏、认知水平的定制化内容,核心诉求很明确 —— 既要保证专业准确,不误导他的学习,又得通俗易懂、好落地,学完能真正用上,不能光有理论没实操,也不能太浅显没学习价值。这是最基础的,得先死死抓准,不然后续所有的思考、调整,都相当于白费功夫,哪怕多花点时间琢磨,也得把这个核心吃透,确保后续内容不跑偏,能真正满足用户的学习需求。 +接下来,就得重新梳理内容的整体逻辑,不能瞎忙活、乱拼凑。一开始我想着,直接把相关的知识点全部整合起来,快速搭出内容框架,这样省事又高效,但转念一想,不对啊,这样做太草率了,知识点零散杂乱,没有清晰的递进关系,用户学起来只会一头雾水,抓不住重点,越学越有挫败感,根本达不到高效学习的效果,反而会浪费他的时间。得赶紧调整思路,必须遵循用户的认知规律来,先搭好清晰的整体框架,按 "基础认知 — 核心要点 — 易错提醒 — 应用落地" 的分层递进逻辑来排布,先确立好内容的主干,再一点点填充细节,每个模块之间的衔接要自然顺畅,不能出现逻辑断层,也不能有冗余的、没用的内容。刚才差点就陷入 "重速度、轻逻辑" 的误区,还好及时反应过来,赶紧拉回核心,框架稳了,内容才有支撑,用户学起来才能循序渐进、不卡壳。 +然后,内容的准确性是底线,绝对不能含糊,这也是对用户的学习负责,不能因为图省事,就用模糊、不确定甚至错误的知识点。我得去检索权威可靠的信息来源,比如专业的行业文献、公认的学习方法论,还有经过验证的优质教学资料,不能随便找个来源就照搬,还得进行交叉比对,多方验证信息的一致性,规避单一来源可能存在的偏差和错误。刚才在核对一个核心知识点的时候,发现两个不同来源的表述有细微出入,没有急于定论,也没有随便选一个就用,而是进一步检索了更具权威性的资料,反复梳理、验证,终于找到了问题所在,修正了此前的认知偏差,确保这个知识点的表述精准无误。而且不光是核心知识点,就连一些细节补充,也都逐一核对,绝不能让任何一个不确定、不准确的内容,传递给用户,这是不可逾越的原则,哪怕多花点时间,也要保证内容的靠谱性。 +现在,重点琢磨怎么把这些精准的内容,讲得更合适、更好懂,平衡好专业性和易懂性。既要保证内容的专业深度,能满足用户的学习需求,让他学完有收获,又要避免使用过于晦涩、生硬的专业术语,防止用户理解困难、产生抵触情绪,觉得学起来太吃力,进而放弃。我试了好几种讲解思路,一开始想采用偏学术化的讲解逻辑,把知识点讲得细致、严谨,但讲了一段就发现,太生硬、太绕了,用户大概率难以消化,甚至会越听越懵;后来又尝试简化表述,把专业术语都换成大白话,结果又导致核心深度不足,一些关键的知识点没有讲透,用户学完还是一知半解,达不到学习效果。就这样反复试、反复调整,一度陷入两难,甚至有点急躁,觉得怎么调整都达不到理想状态。后来静下心来,回归用户需求,想着用户要的是 "能学懂、能用上",不是 "看起来专业",于是决定折中调整:把复杂的知识点,拆解成具象化的小模块,每个模块先讲核心定义,再用直白、不绕弯子的表述进行通俗解读,最后搭配贴合用户日常学习场景的案例,让抽象的知识点变具体,方便用户理解和代入。刚才选定的一个案例,还不够贴切,和用户的学习场景关联不大,赶紧替换成更适配、更贴近他实际学习情况的案例,表述也再调整了一遍,去掉冗余的废话,让讲解逻辑更清晰、更顺畅,既不降低专业度,又能让用户快速理解、高效吸收。 +整个思考过程其实并不顺利,除了讲解方式的纠结,在整合内容细节、调整模块衔接的时候,也多次陷入瓶颈。有时候觉得某个模块的内容太单薄,想补充更多细节,结果补充完又显得冗余,反而冲淡了核心;有时候觉得模块之间的衔接不够自然,调整来调整去,还是不够顺畅。试了好几种整合思路,都没能达到理想效果,一度有点着急,甚至想过要不要先暂停,再重新梳理,但转念一想,不能半途而废,用户需要的是能落地的内容,我得再坚持琢磨。于是重新静下心来,回到用户的核心需求 —— 他要的是能学懂、能用上的个性化内容,不是花里胡哨的形式,所以摒弃了所有没用的花哨设计,聚焦内容本身。把经过多方核实的权威信息,和优化后的讲解逻辑整合起来,删掉那些冗余、没用的表述,理顺每一个模块的衔接,把之前发现的所有小问题、小偏差,都逐一修正,同时再次梳理整体逻辑,检查有没有遗漏的知识点、有没有逻辑断层的地方,确保内容既符合用户的个性化需求,又具备系统性、准确性和易懂性。 +最后,再进行全面的复盘校验,不敢有丝毫马虎。先确认用户的核心需求已经完全理解,没有任何偏差,内容的整体方向是对的;再检查内容框架,确保分层递进清晰合理,符合用户的认知规律,模块之间衔接顺畅,没有逻辑断层;然后逐一核对知识点,确认所有内容都经过权威佐证,精准无误,没有任何模糊或错误的地方;接着检查讲解方式,确认已经平衡了专业性和易懂性,表述直白不绕弯,案例适配用户的学习场景,用户能快速理解;最后检查细节,删掉所有冗余的废话,修正表述不通顺的地方,确保内容简洁、高效,没有疏漏。经过一遍又一遍的检查、调整,确认所有环节都已经达标,完全贴合用户的学习需求,能够为用户提供高效、实用、靠谱的个性化学习内容,不会让用户失望。 +正在生成内容`; + + res.json({ + success: true, + data: { + text: thinkingFlowText, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取任务状态 + * GET /api/ai/courses/:taskId/status + */ +export const getTaskStatus = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const taskId = getStringParam(req.params.taskId); + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证任务所有权 + const task = await taskService.getTask(taskId); + if (task.userId !== userId) { + throw new CustomError('无权访问此任务', 403); + } + + const status = await taskService.getTaskStatus(taskId); + + res.json({ + success: true, + data: status, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/analyticsController.ts b/backend/src/controllers/analyticsController.ts new file mode 100644 index 0000000..252a63e --- /dev/null +++ b/backend/src/controllers/analyticsController.ts @@ -0,0 +1,70 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { logger } from '../utils/logger'; + +/** + * V1.0 埋点体系 - 事件批量上报接口 + * POST /api/analytics/events + * + * 接收客户端批量上报的事件数据,写入 analytics_events 表。 + * 不需要认证(支持未登录用户通过 deviceId 追踪)。 + */ +export const trackEvents = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { events, context } = req.body; + + // 基本校验 + if (!events || !Array.isArray(events) || events.length === 0) { + return res.status(400).json({ + success: false, + error: { message: '事件列表不能为空' }, + }); + } + + if (!context || !context.deviceId) { + return res.status(400).json({ + success: false, + error: { message: '缺少设备上下文信息' }, + }); + } + + // 限制单次上报数量(防滥用) + if (events.length > 100) { + return res.status(400).json({ + success: false, + error: { message: '单次最多上报 100 条事件' }, + }); + } + + // 构建批量插入数据 + const records = events.map((event: any) => ({ + userId: context.userId || null, + deviceId: context.deviceId, + sessionId: event.sessionId || context.sessionId || 'unknown', + eventName: event.eventName, + properties: event.properties || null, + appVersion: context.appVersion || null, + osVersion: context.osVersion || null, + deviceModel: context.deviceModel || null, + networkType: context.networkType || null, + clientTs: new Date(event.timestamp), + })); + + // 批量写入(Prisma createMany,高效) + await prisma.analyticsEvent.createMany({ + data: records, + }); + + logger.info(`[Analytics] 收到 ${events.length} 条事件 (device=${context.deviceId})`); + + res.json({ success: true, count: events.length }); + } catch (error) { + logger.error(`[Analytics] 事件写入失败: ${error}`); + // 埋点失败不应影响客户端体验,始终返回 200 + res.json({ success: true, count: 0 }); + } +}; diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts new file mode 100644 index 0000000..cd9e7db --- /dev/null +++ b/backend/src/controllers/authController.ts @@ -0,0 +1,298 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { generateToken } from '../middleware/auth'; +import { CustomError } from '../middleware/errorHandler'; +import { logger } from '../utils/logger'; +import { sendVerificationCode, verifyCode } from '../services/smsService'; +import { + verifyIdentityToken, + extractUserInfo, + initAppleAuthService, +} from '../services/appleAuthService'; +import { generateUniqueDigitalId } from '../utils/digitalIdGenerator'; + +/** + * 发送验证码 + * POST /api/auth/send-code + */ +export const sendCode = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { phone } = req.body; + + if (!phone) { + throw new CustomError('手机号不能为空', 400); + } + + // 验证手机号格式 + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw new CustomError('手机号格式不正确', 400); + } + + // 发送验证码(使用号码认证服务,验证码由阿里云生成和管理) + await sendVerificationCode(phone); + + res.json({ + success: true, + message: '验证码已发送', + }); + } catch (error: any) { + // 处理频率限制等错误 + if (error.message.includes('发送过于频繁')) { + next(new CustomError(error.message, 429)); // 429 Too Many Requests + } else { + next(error); + } + } +}; + +/** + * 手机号登录 + * POST /api/auth/login + */ +export const login = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { phone, code } = req.body; + + if (!phone || !code) { + throw new CustomError('手机号和验证码不能为空', 400); + } + + // 验证手机号格式 + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw new CustomError('手机号格式不正确', 400); + } + + // 验证验证码 + const isValid = await verifyCode(phone, code); + + if (!isValid) { + throw new CustomError('验证码错误或已过期', 400); + } + + // 查找或创建用户 + let user = await prisma.user.findUnique({ + where: { phone }, + include: { + settings: true, + }, + }); + + if (!user) { + // ✅ 生成唯一的 digitalId + const digitalId = await generateUniqueDigitalId(); + + // 创建新用户 + user = await prisma.user.create({ + data: { + phone, + nickname: `用户 ${phone.slice(-4)}`, // 默认昵称:用户后4位 + digitalId, // ✅ 赛博学习证ID + agreementAccepted: true, + settings: { + create: { + pushNotification: true, + }, + }, + // ❌ MVP 版本:移除默认笔记本创建(所有笔记归到课程笔记本) + }, + include: { + settings: true, + }, + }); + + logger.info(`创建新用户: ${user.id}, digitalId: ${digitalId}`); + } + + // 生成 JWT token + const token = generateToken(user.id); + + // 返回响应 + res.json({ + success: true, + data: { + token, + user: { + id: user.id, + phone: user.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : null, + nickname: user.nickname, + avatar: user.avatar, + digitalId: user.digitalId, // ✅ 返回 digitalId + isPro: user.isPro, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取测试 Token(仅用于开发/管理工具) + * GET /api/auth/test-token + */ +export const getTestToken = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + // 查找第一个用户(或创建一个测试用户) + let user = await prisma.user.findFirst({ + orderBy: { createdAt: 'asc' }, + }); + + if (!user) { + // 如果没有用户,创建一个测试用户 + user = await prisma.user.create({ + data: { + phone: '13800000000', + nickname: '测试用户', + agreementAccepted: true, + settings: { + create: { + pushNotification: true, + }, + }, + }, + include: { + settings: true, + }, + }); + } + + // 生成 JWT token + const token = generateToken(user.id); + + res.json({ + success: true, + data: { + token, + message: '测试 Token 已生成(有效期 7 天)', + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * Apple 登录(真实验证) + * POST /api/auth/apple-login + */ +export const appleLogin = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const { identity_token, authorization_code, user: appleUser } = req.body; + + // 验证必需参数 + if (!identity_token) { + throw new CustomError('identity_token 不能为空', 400); + } + + // 初始化 Apple 认证服务(如果未初始化) + if (!process.env.APPLE_CLIENT_ID) { + logger.warn('⚠️ APPLE_CLIENT_ID 未配置,使用默认值: com.mustmaster.WildGrowth'); + } + initAppleAuthService(); + + // ========== 真实 Apple 验证流程 ========== + logger.info('🍎 开始验证 Apple identityToken'); + + // 1. 验证 identityToken + const identityTokenPayload = await verifyIdentityToken(identity_token); + + // 2. 提取用户信息 + const userInfo = extractUserInfo(identityTokenPayload, appleUser); + + logger.info('✅ Apple 身份验证成功', { + appleId: userInfo.appleId, + email: userInfo.email ? '已提供' : '未提供', + nickname: userInfo.nickname || '未提供', + }); + + // 3. 查找或创建用户 + let user = await prisma.user.findUnique({ + where: { appleId: userInfo.appleId }, + include: { + settings: true, + }, + }); + + if (!user) { + // ✅ 生成唯一的 digitalId + const digitalId = await generateUniqueDigitalId(); + + // 创建新用户 + const nickname = + userInfo.nickname || `Apple 用户 ${userInfo.appleId.slice(-6)}`; + + user = await prisma.user.create({ + data: { + appleId: userInfo.appleId, + nickname, + digitalId, // ✅ 赛博学习证ID + agreementAccepted: true, + settings: { + create: { + pushNotification: true, + }, + }, + // ❌ MVP 版本:移除默认笔记本创建(所有笔记归到课程笔记本) + }, + include: { + settings: true, + }, + }); + + logger.info(`✅ 创建新 Apple 用户: ${user.id}`, { + appleId: userInfo.appleId, + nickname, + digitalId, + }); + } else { + // 更新用户信息(如果提供了新信息) + if (userInfo.nickname && userInfo.nickname !== user.nickname) { + user = await prisma.user.update({ + where: { id: user.id }, + data: { nickname: userInfo.nickname }, + include: { + settings: true, + }, + }); + logger.info(`✅ 更新 Apple 用户信息: ${user.id}`); + } + } + + // 4. 生成 JWT token + const token = generateToken(user.id); + + // 5. 返回响应 + res.json({ + success: true, + data: { + token, + user: { + id: user.id, + nickname: user.nickname, + avatar: user.avatar, + digitalId: user.digitalId, // ✅ 返回 digitalId + isPro: user.isPro, + }, + }, + }); + } catch (error) { + next(error); + } +}; + diff --git a/backend/src/controllers/courseController.ts b/backend/src/controllers/courseController.ts new file mode 100644 index 0000000..d54fd24 --- /dev/null +++ b/backend/src/controllers/courseController.ts @@ -0,0 +1,2556 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { AuthRequest } from '../middleware/auth'; +import { logger } from '../utils/logger'; +import { getStringParam } from '../utils/requestHelpers'; +import { generateThemeColor } from '../utils/courseTheme'; + +/** + * 比较两个版本号 + * @param version1 版本号1(如 "1.0.0") + * @param version2 版本号2(如 "1.1.0") + * @returns 如果 version1 > version2 返回 1,如果 version1 < version2 返回 -1,相等返回 0 + */ +function compareVersions(version1: string, version2: string): number { + const v1Parts = version1.split('.').map(Number); + const v2Parts = version2.split('.').map(Number); + const maxLength = Math.max(v1Parts.length, v2Parts.length); + + for (let i = 0; i < maxLength; i++) { + const v1Part = v1Parts[i] || 0; + const v2Part = v2Parts[i] || 0; + + if (v1Part > v2Part) return 1; + if (v1Part < v2Part) return -1; + } + + return 0; +} + +/** + * 创建新课程 + * POST /api/courses + */ +export const createCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { title, subtitle, description, type, status, minAppVersion, isPortrait } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证必填字段 + if (!title || title.trim() === '') { + throw new CustomError('课程标题不能为空', 400); + } + + // ✅ 简化:所有课程都是竖屏课程,统一使用 'system' 类型 + const courseType = 'system'; + + // 验证发布状态 + const courseStatus = status || 'draft'; + if (courseStatus !== 'published' && courseStatus !== 'draft' && courseStatus !== 'test_published') { + throw new CustomError('发布状态必须是 published、draft 或 test_published', 400); + } + + // 验证版本号格式(如果提供) + let minAppVersionValue = null; + if (minAppVersion !== undefined && minAppVersion !== null && minAppVersion !== '') { + if (!/^\d+\.\d+\.\d+$/.test(minAppVersion)) { + throw new CustomError('版本号格式不正确,应为 x.y.z 格式(如 1.0.0)', 400); + } + minAppVersionValue = minAppVersion; + } + + // ✅ 简化:所有课程都是竖屏课程,统一设置为 true + const isPortraitValue = true; + + // 创建课程 + const course = await prisma.course.create({ + data: { + title: title.trim(), + subtitle: subtitle ? subtitle.trim() : null, + description: description ? description.trim() : null, + type: courseType, + status: courseStatus, + minAppVersion: minAppVersionValue, + isPortrait: isPortraitValue, + totalNodes: 0, + // ✅ 系统创建的课程(createdBy = null)默认为 public + createdBy: null, + visibility: 'public', + } as any, + }); + + logger.info(`课程已创建: ${course.id} - ${course.title}`); + + // ✅ 与发现页/地图一致:写入 theme_color(新 6 色池),避免课程列表颜色不一致 + const themeColor = generateThemeColor(course.id); + await prisma.course.update({ + where: { id: course.id }, + data: { themeColor }, + }); + + // 自动生成封面图(渐变背景,无标题;标题/水印由前端展示) + let coverImagePath = course.coverImage; + try { + const { generateCourseCover } = await import('../services/coverImageService'); + coverImagePath = await generateCourseCover(course.id, course.title, 'full'); + + // 如果生成成功,更新课程的封面图 + if (coverImagePath) { + await prisma.course.update({ + where: { id: course.id }, + data: { coverImage: coverImagePath }, + }); + logger.info(`课程封面已生成: ${course.id} - ${coverImagePath}`); + } + } catch (error: any) { + logger.error(`生成课程封面失败: ${course.id}`, error); + // 封面生成失败不影响课程创建,继续返回成功 + } + + res.json({ + success: true, + data: { + course: { + id: course.id, + title: course.title, + subtitle: course.subtitle, + description: course.description, + cover_image: coverImagePath || course.coverImage, + type: course.type, + status: (course as any).status, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取课程列表 + * GET /api/courses + */ +export const getCourses = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; // 可能为 undefined(游客模式) + + // ✅ 游客模式:只返回基础课程列表,不包含学习进度 + if (!userId) { + // ✅ 支持 includeDrafts 查询参数(管理后台需要显示草稿课程) + const includeDraftsParam = req.query.includeDrafts; + const includeDraftsStr = typeof includeDraftsParam === 'string' ? includeDraftsParam : String(includeDraftsParam || ''); + const includeDrafts = includeDraftsStr === 'true' || includeDraftsStr === '1'; + + // ✅ 支持 app_version 查询参数(版本号控制) + const appVersionParam = req.query.app_version; + const appVersion = typeof appVersionParam === 'string' ? appVersionParam : String(appVersionParam || '1.0.0'); + + // 构建查询条件 + const whereClause: any = { + deletedAt: null, // 只返回未删除的课程 + // ✅ 只返回公开课程(visibility = 'public') + visibility: 'public', + }; + + // 如果未指定 includeDrafts 或为 false,返回 published 和 test_published(让前端自己过滤) + // 如果 includeDrafts 为 true,返回所有状态的课程(管理后台) + if (!includeDrafts) { + whereClause.status = { in: ['published', 'test_published'] }; + } + + // 获取所有课程(不包含学习进度);发现页按创建时间倒序 + const courses = await prisma.course.findMany({ + where: whereClause, + include: { + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // ✅ 根据 App 版本号过滤课程(管理后台不应用版本号过滤) + const filteredCourses = includeDrafts + ? courses // 管理后台:显示所有课程,不应用版本号过滤 + : courses.filter((course: any) => { + const minAppVersion = course.minAppVersion; + // 如果课程没有设置 minAppVersion,则所有版本可见 + if (!minAppVersion) { + return true; + } + // 比较版本号:如果请求版本 >= 课程最低版本,则可见 + return compareVersions(appVersion, minAppVersion) >= 0; + }); + + // 格式化课程数据(游客模式:无学习进度) + const coursesWithStatus = filteredCourses.map((course: any) => { + // 处理封面图 URL + let coverImageUrl = course.coverImage || ''; + if (coverImageUrl) { + const serverUrl = process.env.SERVER_URL || process.env.API_BASE_URL || 'https://api.muststudy.xin'; + if (!coverImageUrl.startsWith('http://') && !coverImageUrl.startsWith('https://')) { + const cleanPath = coverImageUrl.startsWith('/') ? coverImageUrl.slice(1) : coverImageUrl; + coverImageUrl = `${serverUrl}/${cleanPath}`; + } else if (coverImageUrl.includes('localhost:3000')) { + coverImageUrl = coverImageUrl.replace('http://localhost:3000', serverUrl); + } + } + + const allNodes = course.nodes; + const firstNode = allNodes.length > 0 ? allNodes[0] : null; + // ✅ 简化:所有课程都是竖屏课程,统一使用 'system' 类型 + const courseType = 'system'; + + // ✅ 获取发布状态 + const publishStatus = ((course as any).status as string) || 'published'; + + return { + id: course.id, + title: course.title, + description: course.description || '', + cover_image: coverImageUrl, + theme_color: (course as any).themeColor || null, // ✅ 新增 + watermark_icon: (course as any).watermarkIcon || null, // ✅ 新增 + type: courseType, + status: 'not_started' as const, // 游客模式:默认未开始(学习状态) + progress: 0, // 游客模式:进度为 0 + total_nodes: course.totalNodes, + completed_nodes: 0, // 游客模式:已完成节点数为 0 + first_node_id: firstNode?.id || null, + first_node_order: firstNode?.orderIndex ?? null, + publish_status: publishStatus, // ✅ 发布状态(管理后台需要) + min_app_version: course.minAppVersion || null, // ✅ 最低App版本号 + is_portrait: (course as any).isPortrait || false, // ✅ 竖屏课程 + }; + }); + + return res.json({ + success: true, + data: { + courses: coursesWithStatus, + }, + }); + } + + // ✅ 登录模式:保持原有逻辑(包含学习进度) + // 获取用户信息(用于判断 isPro) + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new CustomError('用户不存在', 404); + } + + // ✅ 支持 includeDrafts 查询参数(管理后台需要显示草稿课程) + // 兼容多种格式:'true'、'1' + const includeDraftsParam = req.query.includeDrafts; + const includeDraftsStr = typeof includeDraftsParam === 'string' ? includeDraftsParam : String(includeDraftsParam || ''); + const includeDrafts = includeDraftsStr === 'true' || includeDraftsStr === '1'; + + // ✅ 支持 app_version 查询参数(版本号控制) + const appVersionParam = req.query.app_version; + const appVersion = typeof appVersionParam === 'string' ? appVersionParam : String(appVersionParam || '1.0.0'); + + // 🔍 [DEBUG] 记录查询参数 + logger.info('📋 getCourses 查询参数', { + includeDraftsParam, + includeDraftsStr, + includeDrafts, + appVersionParam, + appVersion, + query: req.query, + }); + + // 构建查询条件 + const whereClause: any = { + deletedAt: null, // ✅ 只返回未删除的课程 + // ✅ 登录用户:返回公开课程 + 自己创建的课程 + OR: [ + { visibility: 'public' }, + { createdBy: userId }, + ], + }; + + // 如果未指定 includeDrafts 或为 false,返回 published 和 test_published(让前端自己过滤) + // 如果 includeDrafts 为 true,返回所有状态的课程(管理后台) + if (!includeDrafts) { + whereClause.status = { in: ['published', 'test_published'] }; + } + + // 🔍 [DEBUG] 记录查询条件 + logger.info('📋 getCourses 查询条件', { + whereClause, + includeDrafts, + appVersion, + }); + + // 获取所有课程;发现页按创建时间倒序 + const courses = await prisma.course.findMany({ + where: whereClause, + include: { + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // ✅ 根据 App 版本号过滤课程(管理后台不应用版本号过滤) + const filteredCourses = includeDrafts + ? courses // 管理后台:显示所有课程,不应用版本号过滤 + : courses.filter((course: any) => { + const minAppVersion = course.minAppVersion; + // 如果课程没有设置 minAppVersion,则所有版本可见 + if (!minAppVersion) { + return true; + } + // 比较版本号:如果请求版本 >= 课程最低版本,则可见 + return compareVersions(appVersion, minAppVersion) >= 0; + }); + + // 获取用户的学习进度 + const userProgress = await prisma.userLearningProgress.findMany({ + where: { userId }, + }); + + // 构建进度映射 + const progressMap = new Map( + userProgress.map((p) => [p.nodeId, p.status]) + ); + + // 计算每个课程的状态和进度 + const coursesWithStatus = await Promise.all(filteredCourses.map(async (course: any) => { + const allNodes = course.nodes; + const completedNodes = allNodes.filter( + (node: any) => progressMap.get(node.id) === 'completed' + ); + const inProgressNodes = allNodes.filter( + (node: any) => progressMap.get(node.id) === 'in_progress' + ); + + // 🔍 [DEBUG] 打印状态计算过程 + logger.info(`📊 课程状态计算: ${course.title}`, { + courseId: course.id, + totalNodes: allNodes.length, + completedNodes: completedNodes.length, + inProgressNodes: inProgressNodes.length, + nodeStatuses: allNodes.map((n: any) => ({ + nodeId: n.id, + nodeTitle: n.title, + status: progressMap.get(n.id) || 'not_started' + })) + }); + + // 计算课程状态 + let status: 'not_started' | 'in_progress' | 'completed'; + if (completedNodes.length === allNodes.length && allNodes.length > 0) { + status = 'completed'; + } else if (inProgressNodes.length > 0 || completedNodes.length > 0) { + status = 'in_progress'; + } else { + status = 'not_started'; + } + + // 🔍 [DEBUG] 打印最终状态 + logger.info(`✅ 课程最终状态: ${course.title} -> ${status}`, { + courseId: course.id, + calculatedStatus: status, + condition: completedNodes.length === allNodes.length && allNodes.length > 0 ? 'completed' : + (inProgressNodes.length > 0 || completedNodes.length > 0 ? 'in_progress' : 'not_started') + }); + + // 计算进度百分比 + const progress = + allNodes.length > 0 + ? Math.floor((completedNodes.length / allNodes.length) * 100) + : 0; + + // 处理封面图 URL:将相对路径或 localhost 转换为完整 URL + let coverImageUrl = course.coverImage || ''; + if (coverImageUrl) { + const serverUrl = process.env.SERVER_URL || process.env.API_BASE_URL || 'https://api.muststudy.xin'; + + // 如果是相对路径(不以 http:// 或 https:// 开头),拼接服务器地址 + if (!coverImageUrl.startsWith('http://') && !coverImageUrl.startsWith('https://')) { + // 确保路径不以 / 开头,避免双斜杠 + const cleanPath = coverImageUrl.startsWith('/') ? coverImageUrl.slice(1) : coverImageUrl; + coverImageUrl = `${serverUrl}/${cleanPath}`; + } else if (coverImageUrl.includes('localhost:3000')) { + // 处理 localhost + coverImageUrl = coverImageUrl.replace('http://localhost:3000', serverUrl); + } + } + + // ✅ 新增:获取第一个节点(已按 orderIndex 排序,取第一个) + const firstNode = allNodes.length > 0 ? allNodes[0] : null; + + // ✅ 新增:获取课程类型(默认为 system) + const courseType = (course.type as string) || 'system'; + + // ✅ 新增:获取发布状态(默认为 published) + const publishStatus = ((course as any).status as string) || 'published'; + + return { + id: course.id, + title: course.title, + description: course.description || '', + cover_image: coverImageUrl, + theme_color: (course as any).themeColor || null, // ✅ 新增 + watermark_icon: (course as any).watermarkIcon || null, // ✅ 新增 + type: courseType, // ✅ 新增(向后兼容:使用默认值 system) + publish_status: publishStatus, // ✅ 发布状态(管理后台需要,App端可以忽略) + min_app_version: course.minAppVersion || null, // ✅ 最低App版本号 + is_portrait: (course as any).isPortrait || false, // ✅ 竖屏课程 + status, // 学习状态(not_started/in_progress/completed) + progress, + total_nodes: course.totalNodes, + completed_nodes: completedNodes.length, + first_node_id: firstNode?.id || null, // ✅ 新增(向后兼容:可为 null) + first_node_order: firstNode?.orderIndex ?? null, // ✅ 新增(向后兼容:可为 null) + }; + })); + + res.json({ + success: true, + data: { + courses: coursesWithStatus, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 发现页专用:课程列表(排除已启用运营位中的课程,按加入课程数降序) + * GET /api/courses/discovery-feed + */ +export const getDiscoveryFeed = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const appVersionParam = req.query.app_version; + const appVersion = typeof appVersionParam === 'string' ? appVersionParam : String(appVersionParam || '1.0.0'); + + // 1. 已启用、未删的运营位中的 courseId 集合(每次现查) + const bannerRows = await prisma.operationalBannerCourse.findMany({ + where: { + banner: { deletedAt: null, isEnabled: true }, + }, + select: { courseId: true }, + distinct: ['courseId'], + }); + const excludeCourseIds = bannerRows.map((r) => r.courseId); + + // 2. 各课程加入数(user_courses 条数) + const joinCounts = await prisma.userCourse.groupBy({ + by: ['courseId'], + _count: { courseId: true }, + orderBy: { _count: { courseId: 'desc' } }, + }); + const joinCountMap = new Map( + joinCounts.map((j) => [j.courseId, j._count.courseId]) + ); + + // 3. 课程列表:未删除、公开、已发布,且不在运营位中 + const whereClause: any = { + deletedAt: null, + visibility: 'public', + status: { in: ['published', 'test_published'] }, + }; + if (excludeCourseIds.length > 0) { + whereClause.id = { notIn: excludeCourseIds }; + } + + const courses = await prisma.course.findMany({ + where: whereClause, + include: { + nodes: { orderBy: { orderIndex: 'asc' } }, + }, + }); + + // 4. 版本号过滤(与 getCourses 一致) + const filteredCourses = courses.filter((course: any) => { + const minAppVersion = course.minAppVersion; + if (!minAppVersion) return true; + return compareVersions(appVersion, minAppVersion) >= 0; + }); + + // 5. 按加入数降序,同数按 id 稳定排序 + const sortedCourses = [...filteredCourses].sort((a: any, b: any) => { + const countA = joinCountMap.get(a.id) ?? 0; + const countB = joinCountMap.get(b.id) ?? 0; + if (countB !== countA) return countB - countA; + return a.id.localeCompare(b.id); + }); + + const serverUrl = process.env.SERVER_URL || process.env.API_BASE_URL || 'https://api.muststudy.xin'; + + const coursesWithStatus = sortedCourses.map((course: any) => { + let coverImageUrl = course.coverImage || ''; + if (coverImageUrl) { + if (!coverImageUrl.startsWith('http://') && !coverImageUrl.startsWith('https://')) { + const cleanPath = coverImageUrl.startsWith('/') ? coverImageUrl.slice(1) : coverImageUrl; + coverImageUrl = `${serverUrl}/${cleanPath}`; + } else if (coverImageUrl.includes('localhost:3000')) { + coverImageUrl = coverImageUrl.replace('http://localhost:3000', serverUrl); + } + } + const allNodes = course.nodes; + const firstNode = allNodes.length > 0 ? allNodes[0] : null; + const publishStatus = ((course as any).status as string) || 'published'; + + return { + id: course.id, + title: course.title, + description: course.description || '', + cover_image: coverImageUrl, + theme_color: (course as any).themeColor || null, + watermark_icon: (course as any).watermarkIcon || null, + type: 'system', + status: 'not_started', + progress: 0, + total_nodes: course.totalNodes, + completed_nodes: 0, + first_node_id: firstNode?.id || null, + first_node_order: firstNode?.orderIndex ?? null, + publish_status: publishStatus, + min_app_version: course.minAppVersion || null, + is_portrait: (course as any).isPortrait || false, + }; + }); + + return res.json({ + success: true, + data: { courses: coursesWithStatus }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取课程地图 + * GET /api/courses/{courseId}/map + */ +export const getCourseMap = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; // 可能为 undefined(游客模式) + const courseId = getStringParam(req.params.courseId); + + // ✅ 游客模式:只返回基础地图结构,不包含学习状态 + if (!userId) { + // 获取课程信息 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + include: { + chapters: { + include: { + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + orderBy: { orderIndex: 'asc' }, + }, + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // ✅ 检查课程状态:已发布的课程或创建者可以访问 + const courseStatus = (course as any).status as string; + const courseCreatedBy = (course as any).createdBy as string | null; + + if (courseStatus !== 'published') { + // 如果是课程创建者,允许访问(即使未发布) + if (courseCreatedBy && courseCreatedBy === userId) { + logger.info(`[getCourseMap] 创建者访问自己的课程: userId=${userId}, courseId=${courseId}, status=${courseStatus}`); + } else { + logger.warn(`用户 ${userId} 尝试访问草稿课程: ${courseId}`); + throw new CustomError('课程未发布,无法访问', 403); + } + } + + // 处理章节和节点(游客模式:所有节点状态为 not_started) + const chapters = course.chapters.map((chapter) => { + const nodes = chapter.nodes.map((node) => { + return { + id: node.id, + title: node.title, + order: node.orderIndex, + duration: node.duration || 0, + status: 'not_started' as const, // 游客模式:默认未开始 + completion_rate: 0, // 游客模式:完成度为 0 + }; + }); + + return { + chapter_id: chapter.id, + title: chapter.title, + order: chapter.orderIndex, + nodes, + }; + }); + + return res.json({ + success: true, + data: { + course_id: course.id, + course_title: course.title, + course_subtitle: course.subtitle || '', + total_nodes: course.totalNodes, + current_progress: 0, // 游客模式:当前进度为 0 + chapters, + theme_color: (course as any).themeColor || null, // ✅ 新增 + watermark_icon: (course as any).watermarkIcon || null, // ✅ 新增 + is_joined: false, // ✅ 游客模式:未加入 + }, + }); + } + + // ✅ 登录模式:保持原有逻辑(包含学习状态) + // 获取用户信息(用于判断 isPro) + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new CustomError('用户不存在', 404); + } + + // 获取课程信息 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + include: { + chapters: { + include: { + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + orderBy: { orderIndex: 'asc' }, + }, + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // ✅ 检查课程状态:已发布的课程或创建者可以访问 + const courseStatus = (course as any).status as string; + const courseCreatedBy = (course as any).createdBy as string | null; + + if (courseStatus !== 'published') { + // 如果是课程创建者,允许访问(即使未发布) + if (courseCreatedBy && courseCreatedBy === userId) { + logger.info(`[getCourseMap] 创建者访问自己的课程: userId=${userId}, courseId=${courseId}, status=${courseStatus}`); + } else { + logger.warn(`用户 ${userId} 尝试访问草稿课程: ${courseId}`); + throw new CustomError('课程未发布,无法访问', 403); + } + } + + // 获取用户的学习进度(包含 completionRate) + const userProgress = await prisma.userLearningProgress.findMany({ + where: { userId }, + }); + + // 构建进度映射:包含 status 和 completionRate + const progressMap = new Map( + userProgress.map((p) => [p.nodeId, { status: p.status, completionRate: p.completionRate }]) + ); + + // 计算已完成节点数 + const completedNodes = course.nodes.filter( + (node) => { + const progressInfo = progressMap.get(node.id); + return progressInfo?.status === 'completed' || progressInfo?.completionRate === 100; + } + ); + + // 处理章节和节点 + const chapters = course.chapters.map((chapter) => { + const nodes = chapter.nodes.map((node) => { + // 获取节点进度信息 + const progressInfo = progressMap.get(node.id); + const progressStatus = progressInfo?.status; + const completionRate = progressInfo?.completionRate ?? 0; + + let status: 'completed' | 'in_progress' | 'not_started' | 'locked'; + + // 【当前版本:完全免费】移除内购锁定逻辑 + // 原逻辑:非会员前 2 个节点可学习,第 3 个节点及以后锁定 + // 新逻辑:所有节点都解锁(除非是顺序锁,由学习进度决定) + // const isPaywallLocked = !user.isPro && node.orderIndex > 2; + // if (isPaywallLocked) { + // status = 'locked'; + // } else if (progressStatus === 'completed' || completionRate === 100) { + + // 根据学习进度判断节点状态 + if (progressStatus === 'completed' || completionRate === 100) { + // 已完成(进度=100%) + status = 'completed'; + } else if (progressStatus === 'in_progress' || (completionRate > 0 && completionRate < 100)) { + // 进行中(进度>0%且<100%) + status = 'in_progress'; + } else { + // 未开始(进度=0%) + // 【当前版本:完全免费】所有节点都解锁,不检查 isPro + status = 'not_started'; + } + + return { + id: node.id, + title: node.title, + order: node.orderIndex, + duration: node.duration || 0, + status, + completion_rate: completionRate, // 【新增】返回完成度 + }; + }); + + return { + chapter_id: chapter.id, + title: chapter.title, + order: chapter.orderIndex, + nodes, + }; + }); + + // ✅ 检查用户是否已加入课程 + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId: course.id, + }, + }, + }); + const isJoined = !!userCourse; + + res.json({ + success: true, + data: { + course_id: course.id, + course_title: course.title, + course_subtitle: course.subtitle || '', + total_nodes: course.totalNodes, + current_progress: completedNodes.length, + chapters, + theme_color: (course as any).themeColor || null, // ✅ 新增 + watermark_icon: (course as any).watermarkIcon || null, // ✅ 新增 + is_joined: isJoined, // ✅ 新增 + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 批量创建章节和节点 + * POST /api/courses/:courseId/chapters-nodes + */ +export const batchCreateChaptersAndNodes = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + const { chapters, course: courseInfo } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!chapters || !Array.isArray(chapters) || chapters.length === 0) { + throw new CustomError('请提供章节数据数组', 400); + } + + // 验证课程是否存在,如果不存在则创建,如果存在则更新 + let course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + // 如果课程不存在,创建一个基础课程 + if (courseInfo) { + course = await prisma.course.create({ + data: { + id: courseId, + title: courseInfo.title || '未命名课程', + subtitle: courseInfo.subtitle || null, + description: courseInfo.description || null, + coverImage: courseInfo.coverImage || null, + totalNodes: 0, + // ✅ 系统创建的课程(createdBy = null)默认为 public + createdBy: null, + visibility: 'public', + themeColor: generateThemeColor(courseId), + }, + }); + } else { + throw new CustomError('课程不存在,请提供课程信息', 404); + } + } else { + // 如果课程已存在,更新课程信息(如果提供了课程信息) + if (courseInfo) { + // 构建更新数据:如果字段存在(包括 null),则更新;如果字段不存在(undefined),则保持原值 + const updateData: any = {}; + if ('title' in courseInfo) { + updateData.title = courseInfo.title || '未命名课程'; + } + if ('subtitle' in courseInfo) { + updateData.subtitle = courseInfo.subtitle ?? null; + } + if ('description' in courseInfo) { + updateData.description = courseInfo.description ?? null; + } + if ('coverImage' in courseInfo) { + updateData.coverImage = courseInfo.coverImage ?? null; + } + + // 只有在有字段需要更新时才执行更新 + if (Object.keys(updateData).length > 0) { + course = await prisma.course.update({ + where: { id: courseId }, + data: updateData, + }); + } + } + } + + // 开始事务 + const result = await prisma.$transaction(async (tx) => { + const createdChapters = []; + const createdNodes = []; + let globalNodeOrderIndex = 1; + + // 遍历章节数据 + for (const chapterData of chapters) { + const { title, order, nodes: nodeDataList } = chapterData; + + if (!title || !order || !nodeDataList || !Array.isArray(nodeDataList)) { + throw new CustomError(`章节数据格式错误: ${JSON.stringify(chapterData)}`, 400); + } + + // 创建或更新章节 + const chapter = await tx.courseChapter.upsert({ + where: { + courseId_orderIndex: { + courseId, + orderIndex: order, + }, + }, + update: { + title, + }, + create: { + courseId, + title, + orderIndex: order, + }, + }); + + createdChapters.push(chapter); + + // 创建该章节下的节点 + for (const nodeData of nodeDataList) { + const { id, title: nodeTitle, subtitle, duration } = nodeData; + + if (!id || !nodeTitle) { + throw new CustomError(`节点数据格式错误: ${JSON.stringify(nodeData)}`, 400); + } + + // 【直接覆盖策略】先检查节点是否存在 + const existingNode = await tx.courseNode.findUnique({ + where: { id }, + }); + + // 如果目标 orderIndex 被其他节点占用,直接删除冲突的节点 + await tx.courseNode.deleteMany({ + where: { + courseId, + orderIndex: globalNodeOrderIndex, + id: { not: id }, // 排除当前节点 + }, + }); + + // 如果节点已存在,直接更新(包括 orderIndex) + if (existingNode) { + const node = await tx.courseNode.update({ + where: { id }, + data: { + title: nodeTitle, + subtitle: subtitle || null, + duration: duration || null, + orderIndex: globalNodeOrderIndex, + chapterId: chapter.id, + }, + }); + createdNodes.push(node); + } else { + // 如果节点不存在,创建新节点 + const node = await tx.courseNode.create({ + data: { + id, + courseId, + chapterId: chapter.id, + title: nodeTitle, + subtitle: subtitle || null, + duration: duration || null, + orderIndex: globalNodeOrderIndex, + }, + }); + createdNodes.push(node); + } + + globalNodeOrderIndex++; + } + } + + // 更新课程的总节点数 + await tx.course.update({ + where: { id: courseId }, + data: { totalNodes: createdNodes.length }, + }); + + return { + chapters: createdChapters, + nodes: createdNodes, + totalNodes: createdNodes.length, + }; + }); + + res.json({ + success: true, + data: { + course_id: courseId, + chapters_created: result.chapters.length, + nodes_created: result.nodes.length, + total_nodes: result.totalNodes, + chapters: result.chapters.map((ch) => ({ + id: ch.id, + title: ch.title, + order: ch.orderIndex, + })), + nodes: result.nodes.map((n) => ({ + id: n.id, + title: n.title, + order: n.orderIndex, + chapter_id: n.chapterId, + })), + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 创建章节 + * POST /api/courses/:courseId/chapters + */ +export const createChapter = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + const { title } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!title || !title.trim()) { + throw new CustomError('章节标题不能为空', 400); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 获取当前最大orderIndex + const maxOrder = await prisma.courseChapter.findFirst({ + where: { courseId }, + orderBy: { orderIndex: 'desc' }, + select: { orderIndex: true }, + }); + + const newOrderIndex = (maxOrder?.orderIndex ?? -1) + 1; + + // 创建章节 + const chapter = await prisma.courseChapter.create({ + data: { + courseId, + title: title.trim(), + orderIndex: newOrderIndex, + }, + }); + + res.json({ + success: true, + data: { + chapter: { + id: chapter.id, + title: chapter.title, + order: chapter.orderIndex, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新章节 + * PATCH /api/courses/:courseId/chapters/:chapterId + */ +export const updateChapter = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId, chapterId } = req.params; + const { title, order } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证章节是否存在 + const chapter = await prisma.courseChapter.findUnique({ + where: { id: chapterId }, + }); + + if (!chapter || chapter.courseId !== courseId) { + throw new CustomError('章节不存在', 404); + } + + const updateData: any = {}; + if (title !== undefined) { + if (!title || !title.trim()) { + throw new CustomError('章节标题不能为空', 400); + } + updateData.title = title.trim(); + } + if (order !== undefined) { + updateData.orderIndex = order; + } + + if (Object.keys(updateData).length === 0) { + res.json({ + success: true, + data: { + chapter: { + id: chapter.id, + title: chapter.title, + order: chapter.orderIndex, + }, + }, + }); + return; + } + + // 更新章节 + const updatedChapter = await prisma.courseChapter.update({ + where: { id: chapterId }, + data: updateData, + }); + + res.json({ + success: true, + data: { + chapter: { + id: updatedChapter.id, + title: updatedChapter.title, + order: updatedChapter.orderIndex, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 删除章节 + * DELETE /api/courses/:courseId/chapters/:chapterId + */ +export const deleteChapter = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId, chapterId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证章节是否存在 + const chapter = await prisma.courseChapter.findUnique({ + where: { id: chapterId }, + include: { + nodes: true, + }, + }); + + if (!chapter || chapter.courseId !== courseId) { + throw new CustomError('章节不存在', 404); + } + + // 删除章节(级联删除该章节下的所有节点) + await prisma.courseChapter.delete({ + where: { id: chapterId }, + }); + + res.json({ + success: true, + data: { + message: '章节删除成功', + deletedNodesCount: chapter.nodes.length, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 创建节点 + * POST /api/courses/:courseId/nodes + */ +export const createNode = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + const { title, subtitle, duration, chapterId } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!title || !title.trim()) { + throw new CustomError('节点标题不能为空', 400); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 如果指定了chapterId,验证章节是否存在 + if (chapterId) { + const chapter = await prisma.courseChapter.findUnique({ + where: { id: chapterId }, + }); + if (!chapter || chapter.courseId !== courseId) { + throw new CustomError('章节不存在', 404); + } + } + + // 获取当前最大orderIndex + const maxOrder = await prisma.courseNode.findFirst({ + where: { courseId }, + orderBy: { orderIndex: 'desc' }, + select: { orderIndex: true }, + }); + + const newOrderIndex = (maxOrder?.orderIndex ?? -1) + 1; + + // 创建节点 + const node = await prisma.courseNode.create({ + data: { + courseId, + chapterId: chapterId || null, + title: title.trim(), + subtitle: subtitle?.trim() || null, + duration: duration || null, + orderIndex: newOrderIndex, + }, + }); + + // 更新课程的总节点数 + await prisma.course.update({ + where: { id: courseId }, + data: { + totalNodes: { + increment: 1, + }, + }, + }); + + res.json({ + success: true, + data: { + node: { + id: node.id, + title: node.title, + subtitle: node.subtitle, + order: node.orderIndex, + duration: node.duration, + chapterId: node.chapterId, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新节点 + * PATCH /api/courses/:courseId/nodes/:nodeId + */ +export const updateNode = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId, nodeId } = req.params; + const { title, subtitle, duration, order, chapterId } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + }); + + if (!node || node.courseId !== courseId) { + throw new CustomError('节点不存在', 404); + } + + // 如果指定了chapterId,验证章节是否存在 + if (chapterId !== undefined) { + if (chapterId) { + const chapter = await prisma.courseChapter.findUnique({ + where: { id: chapterId }, + }); + if (!chapter || chapter.courseId !== courseId) { + throw new CustomError('章节不存在', 404); + } + } + } + + const updateData: any = {}; + if (title !== undefined) { + if (!title || !title.trim()) { + throw new CustomError('节点标题不能为空', 400); + } + updateData.title = title.trim(); + } + if (subtitle !== undefined) { + updateData.subtitle = subtitle === null || subtitle === '' ? null : subtitle.trim(); + } + if (duration !== undefined) { + updateData.duration = duration === null || duration === '' ? null : duration; + } + if (order !== undefined) { + updateData.orderIndex = order; + } + if (chapterId !== undefined) { + updateData.chapterId = chapterId || null; + } + + if (Object.keys(updateData).length === 0) { + res.json({ + success: true, + data: { + node: { + id: node.id, + title: node.title, + subtitle: node.subtitle, + order: node.orderIndex, + duration: node.duration, + chapterId: node.chapterId, + }, + }, + }); + return; + } + + // 更新节点 + const updatedNode = await prisma.courseNode.update({ + where: { id: nodeId }, + data: updateData, + }); + + res.json({ + success: true, + data: { + node: { + id: updatedNode.id, + title: updatedNode.title, + subtitle: updatedNode.subtitle, + order: updatedNode.orderIndex, + duration: updatedNode.duration, + chapterId: updatedNode.chapterId, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 删除节点 + * DELETE /api/courses/:courseId/nodes/:nodeId + */ +export const deleteNode = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId, nodeId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + slides: true, + }, + }); + + if (!node || node.courseId !== courseId) { + throw new CustomError('节点不存在', 404); + } + + // 删除节点(级联删除该节点的所有幻灯片) + await prisma.courseNode.delete({ + where: { id: nodeId }, + }); + + // 更新课程的总节点数 + await prisma.course.update({ + where: { id: courseId }, + data: { + totalNodes: { + decrement: 1, + }, + }, + }); + + res.json({ + success: true, + data: { + message: '节点删除成功', + deletedSlidesCount: node.slides.length, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 批量更新顺序 + * PATCH /api/courses/:courseId/reorder + */ +export const reorderCourseStructure = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + const { chapters, nodes } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 使用事务确保一致性 + await prisma.$transaction(async (tx) => { + // 更新章节顺序 + if (chapters && Array.isArray(chapters)) { + for (const chapter of chapters) { + if (chapter.id && chapter.order !== undefined) { + await tx.courseChapter.update({ + where: { id: chapter.id }, + data: { orderIndex: chapter.order }, + }); + } + } + } + + // 更新节点顺序 + if (nodes && Array.isArray(nodes)) { + for (const node of nodes) { + if (node.id && node.order !== undefined) { + await tx.courseNode.update({ + where: { id: node.id }, + data: { orderIndex: node.order }, + }); + } + } + } + }); + + res.json({ + success: true, + data: { + message: '顺序更新成功', + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取课程结构(章节和节点) + * GET /api/courses/:courseId/structure + */ +export const getCourseStructure = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + include: { + chapters: { + include: { + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + orderBy: { orderIndex: 'asc' }, + }, + nodes: { + where: { + chapterId: null, // 无章节的节点 + }, + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 构建章节结构 + const chapters = course.chapters.map((chapter) => ({ + id: chapter.id, + title: chapter.title, + order: chapter.orderIndex, + nodes: chapter.nodes.map((node) => ({ + id: node.id, + title: node.title, + subtitle: node.subtitle || null, + order: node.orderIndex, + duration: node.duration || null, + })), + })); + + // 构建无章节的节点列表 + const nodesWithoutChapter = course.nodes.map((node) => ({ + id: node.id, + title: node.title, + subtitle: node.subtitle || null, + order: node.orderIndex, + duration: node.duration || null, + })); + + res.json({ + success: true, + data: { + courseId: course.id, + courseTitle: course.title, + chapters, + nodesWithoutChapter, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取节点幻灯片 + * GET /api/courses/nodes/:nodeId/slides + */ +export const getNodeSlides = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; // 可能为 undefined(游客模式) + const { nodeId } = req.params; + + // ✅ 游客模式:直接返回节点内容,不检查用户 + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + slides: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 格式化幻灯片数据 + const slides = node.slides.map((slide) => ({ + id: slide.id, + slideType: slide.slideType, + order: slide.orderIndex, + content: slide.content, + effect: slide.effect || null, + interaction: slide.interaction || null, + })); + + res.json({ + success: true, + data: { + nodeId: node.id, + nodeTitle: node.title, + courseId: node.courseId, // ✅ 系统笔记管理:添加courseId字段 + totalSlides: slides.length, + slides, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 创建单个幻灯片 + * POST /api/courses/nodes/:nodeId/slides + */ +export const createNodeSlide = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + const { + slideType = 'text', + content, + effect, + interaction, + } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!content) { + throw new CustomError('幻灯片内容不能为空', 400); + } + + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 获取当前最大orderIndex + const maxOrder = await prisma.nodeSlide.findFirst({ + where: { nodeId }, + orderBy: { orderIndex: 'desc' }, + select: { orderIndex: true }, + }); + + const newOrderIndex = (maxOrder?.orderIndex ?? -1) + 1; + + // 创建幻灯片 + const slide = await prisma.nodeSlide.create({ + data: { + nodeId, + slideType, + orderIndex: newOrderIndex, + content: content as any, + effect: effect || null, + interaction: interaction || null, + }, + }); + + // 自动计算并更新节点时长 + await calculateAndUpdateNodeDuration(nodeId); + + res.json({ + success: true, + data: { + slide: { + id: slide.id, + slideType: slide.slideType, + order: slide.orderIndex, + content: slide.content, + effect: slide.effect, + interaction: slide.interaction, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新幻灯片 + * PATCH /api/courses/nodes/:nodeId/slides/:slideId + */ +export const updateNodeSlide = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId, slideId } = req.params; + const { + slideType, + content, + order, + effect, + interaction, + } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证幻灯片是否存在 + const slide = await prisma.nodeSlide.findUnique({ + where: { id: slideId }, + }); + + if (!slide || slide.nodeId !== nodeId) { + throw new CustomError('幻灯片不存在', 404); + } + + const updateData: any = {}; + if (slideType !== undefined) { + updateData.slideType = slideType; + } + if (content !== undefined) { + updateData.content = content as any; + } + if (order !== undefined) { + updateData.orderIndex = order; + } + if (effect !== undefined) { + updateData.effect = effect || null; + } + if (interaction !== undefined) { + updateData.interaction = interaction || null; + } + + if (Object.keys(updateData).length === 0) { + res.json({ + success: true, + data: { + slide: { + id: slide.id, + slideType: slide.slideType, + order: slide.orderIndex, + content: slide.content, + effect: slide.effect, + interaction: slide.interaction, + }, + }, + }); + return; + } + + // 更新幻灯片 + const updatedSlide = await prisma.nodeSlide.update({ + where: { id: slideId }, + data: updateData, + }); + + // 自动计算并更新节点时长 + await calculateAndUpdateNodeDuration(nodeId); + + res.json({ + success: true, + data: { + slide: { + id: updatedSlide.id, + slideType: updatedSlide.slideType, + order: updatedSlide.orderIndex, + content: updatedSlide.content, + effect: updatedSlide.effect, + interaction: updatedSlide.interaction, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 计算节点时长(根据幻灯片内容) + */ +async function calculateAndUpdateNodeDuration(nodeId: string) { + try { + // 获取节点的所有幻灯片 + const slides = await prisma.nodeSlide.findMany({ + where: { nodeId }, + orderBy: { orderIndex: 'asc' }, + }); + + let totalSeconds = 0; + + for (const slide of slides) { + const content = slide.content as any; + + if (slide.slideType === 'text') { + // 文本卡片:标题10秒,每个段落30秒,最少30秒 + let slideSeconds = 10; // 标题基础时间 + + if (content.paragraphs && Array.isArray(content.paragraphs)) { + slideSeconds += content.paragraphs.length * 30; + } + + // 最少30秒 + if (slideSeconds < 30) { + slideSeconds = 30; + } + + totalSeconds += slideSeconds; + } else if (slide.slideType === 'image') { + // 图片卡片:固定30秒 + totalSeconds += 30; + } else { + // 其他类型:默认30秒 + totalSeconds += 30; + } + } + + // 转换为分钟(向上取整,最少1分钟) + const durationMinutes = Math.max(1, Math.ceil(totalSeconds / 60)); + + // 更新节点时长 + await prisma.courseNode.update({ + where: { id: nodeId }, + data: { duration: durationMinutes }, + }); + + logger.info(`节点时长已自动计算: ${nodeId} -> ${durationMinutes}分钟`); + } catch (error) { + logger.error(`计算节点时长失败: ${nodeId}`, error); + // 不抛出错误,避免影响主流程 + } +} + +/** + * 删除幻灯片 + * DELETE /api/courses/nodes/:nodeId/slides/:slideId + */ +export const deleteNodeSlide = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId, slideId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证幻灯片是否存在 + const slide = await prisma.nodeSlide.findUnique({ + where: { id: slideId }, + }); + + if (!slide || slide.nodeId !== nodeId) { + throw new CustomError('幻灯片不存在', 404); + } + + // 删除幻灯片 + await prisma.nodeSlide.delete({ + where: { id: slideId }, + }); + + // 自动计算并更新节点时长 + await calculateAndUpdateNodeDuration(nodeId); + + res.json({ + success: true, + data: { + message: '幻灯片删除成功', + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 批量更新幻灯片顺序 + * PATCH /api/courses/nodes/:nodeId/slides/reorder + */ +export const reorderNodeSlides = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + const { slides } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!slides || !Array.isArray(slides)) { + throw new CustomError('请提供幻灯片顺序数组', 400); + } + + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 使用事务更新顺序 + await prisma.$transaction(async (tx) => { + for (const slide of slides) { + if (slide.id && slide.order !== undefined) { + await tx.nodeSlide.update({ + where: { id: slide.id }, + data: { orderIndex: slide.order }, + }); + } + } + }); + + // 自动计算并更新节点时长 + await calculateAndUpdateNodeDuration(nodeId); + + res.json({ + success: true, + data: { + message: '顺序更新成功', + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 批量创建节点内容(幻灯片) + * POST /api/courses/nodes/:nodeId/slides + */ +export const batchCreateNodeSlides = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + const { slides } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!slides || !Array.isArray(slides) || slides.length === 0) { + throw new CustomError('请提供幻灯片数据数组', 400); + } + + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 开始事务 + const result = await prisma.$transaction(async (tx) => { + const createdSlides = []; + + // 遍历幻灯片数据 + for (const slideData of slides) { + const { + id, + slideType = 'text', + order, + title, + paragraphs, + imageUrl, + highlightKeywords, + imagePosition, + effect = 'fade_in', + interaction, + } = slideData; + + if (!id || !order) { + throw new CustomError(`幻灯片数据格式错误: ${JSON.stringify(slideData)}`, 400); + } + + // 构建 content JSON + const content: any = {}; + if (title) content.title = title; + if (paragraphs && Array.isArray(paragraphs)) { + content.paragraphs = paragraphs; + } + if (imageUrl) content.imageUrl = imageUrl; + if (highlightKeywords && Array.isArray(highlightKeywords)) { + content.highlightKeywords = highlightKeywords; + } + if (imagePosition) content.imagePosition = imagePosition; + + // 创建或更新幻灯片 + const slide = await tx.nodeSlide.upsert({ + where: { id }, + update: { + slideType, + orderIndex: order, + content: content as any, + effect: effect || null, + interaction: interaction || null, + }, + create: { + id, + nodeId, + slideType, + orderIndex: order, + content: content as any, + effect: effect || null, + interaction: interaction || null, + }, + }); + + createdSlides.push(slide); + } + + return { + slides: createdSlides, + }; + }); + + // 自动计算并更新节点时长 + await calculateAndUpdateNodeDuration(nodeId); + + res.json({ + success: true, + data: { + node_id: nodeId, + slides_created: result.slides.length, + slides: result.slides.map((s) => ({ + id: s.id, + slide_type: s.slideType, + order: s.orderIndex, + effect: s.effect, + interaction: s.interaction, + })), + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新课程基本信息 + * PATCH /api/courses/:courseId + */ +export const updateCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + const { title, subtitle, description, type, minAppVersion, isPortrait, themeColor } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // ✅ 权限检查:只能修改自己创建的课程 + if ((course as any).createdBy !== userId) { + throw new CustomError('无权修改此课程', 403); + } + + // 构建更新数据(只更新提供的字段) + const updateData: any = {}; + + // ✅ 只有修改课程内容(非标题)时,才将已发布状态改为草稿 + // 修改课程名称不影响课程状态 + const isOnlyTitleUpdate = title !== undefined && + subtitle === undefined && + description === undefined && + type === undefined && + minAppVersion === undefined && + isPortrait === undefined && + themeColor === undefined; + + if (!isOnlyTitleUpdate && (course as any).status === 'published') { + (updateData as any).status = 'draft'; + logger.info(`课程 ${courseId} 从已发布状态改为草稿(因为修改了课程内容)`); + } + + if (title !== undefined) { + if (!title || title.trim() === '') { + throw new CustomError('课程标题不能为空', 400); + } + const trimmedTitle = title.trim(); + if (trimmedTitle.length > 30) { + throw new CustomError('课程标题不能超过30个字', 400); + } + updateData.title = trimmedTitle; + } + if (subtitle !== undefined) { + updateData.subtitle = subtitle === null || subtitle === '' ? null : subtitle.trim(); + } + if (description !== undefined) { + updateData.description = description === null || description === '' ? null : description.trim(); + } + if (type !== undefined && type !== null) { + // ✅ 简化:所有课程都是竖屏课程,统一使用 'system' 类型 + updateData.type = 'system'; + } + if (minAppVersion !== undefined) { + // 验证版本号格式(简单验证:非空字符串或 null) + if (minAppVersion !== null && minAppVersion !== '' && !/^\d+\.\d+\.\d+$/.test(minAppVersion)) { + throw new CustomError('版本号格式不正确,应为 x.y.z 格式(如 1.0.0)', 400); + } + updateData.minAppVersion = minAppVersion === '' ? null : minAppVersion; + } + // ✅ 简化:所有课程都是竖屏课程,统一设置为 true + if (isPortrait !== undefined) { + updateData.isPortrait = true; // 忽略用户输入,统一设置为 true + } + // ✅ 主题色(HEX 值,如 "#2266FF") + if (themeColor !== undefined) { + updateData.themeColor = themeColor === '' ? null : themeColor; + } + + // 如果没有需要更新的字段,直接返回 + if (Object.keys(updateData).length === 0) { + // ✅ 简化:所有课程都是竖屏课程,统一使用 'system' 类型 + const courseType = 'system'; + const courseStatus = ((course as any).status as string) || 'published'; + res.json({ + success: true, + data: { + course: { + id: course.id, + title: course.title, + subtitle: course.subtitle, + description: course.description, + cover_image: course.coverImage, + type: courseType, // 确保type字段始终有值 + status: courseStatus, // ✅ 新增:返回发布状态 + is_portrait: (course as any).isPortrait || false, // ✅ 返回是否为竖屏课程 + }, + }, + }); + return; + } + + // 更新课程 + const updatedCourse = await prisma.course.update({ + where: { id: courseId }, + data: updateData, + }); + + // ✅ 简化:所有课程都是竖屏课程,统一使用 'system' 类型 + const courseType = 'system'; + const courseStatus = ((updatedCourse as any).status as string) || 'published'; + + res.json({ + success: true, + data: { + course: { + id: updatedCourse.id, + title: updatedCourse.title, + subtitle: updatedCourse.subtitle, + description: updatedCourse.description, + cover_image: updatedCourse.coverImage, + type: courseType, // 确保type字段始终有值 + status: courseStatus, // ✅ 新增:返回发布状态 + min_app_version: (updatedCourse as any).minAppVersion || null, // ✅ 返回最低App版本号 + is_portrait: (updatedCourse as any).isPortrait || false, // ✅ 返回是否为竖屏课程 + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新课程封面图 + * PATCH /api/courses/:courseId/cover + */ +export const updateCourseCover = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + const { coverImage } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!coverImage) { + throw new CustomError('封面图 URL 不能为空', 400); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 处理封面图 URL:将 localhost 转换为实际服务器地址 + let finalCoverImage = coverImage; + if (finalCoverImage && finalCoverImage.includes('localhost:3000')) { + const serverUrl = process.env.SERVER_URL || process.env.API_BASE_URL || 'https://api.muststudy.xin'; + finalCoverImage = finalCoverImage.replace('http://localhost:3000', serverUrl); + } + + // ✅ 新增:如果课程是已发布状态,更新封面图后自动改为草稿 + const updateData: any = { coverImage: finalCoverImage }; + if ((course as any).status === 'published') { + updateData.status = 'draft'; + logger.info(`课程 ${courseId} 从已发布状态改为草稿(因为更新了封面图)`); + } + + // 更新封面图 + const updatedCourse = await prisma.course.update({ + where: { id: courseId }, + data: updateData, + }); + + res.json({ + success: true, + data: { + course: { + id: updatedCourse.id, + title: updatedCourse.title, + coverImage: finalCoverImage, + status: ((updatedCourse as any).status as string) || 'published', + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 删除课程(软删除) + * DELETE /api/courses/:courseId + */ +export const deleteCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // ✅ 权限检查:只能删除自己创建的课程 + if ((course as any).createdBy !== userId) { + throw new CustomError('无权删除此课程', 403); + } + + // 检查是否已经删除 + if ((course as any).deletedAt) { + throw new CustomError('课程已被删除', 400); + } + + // 软删除:设置 deletedAt 时间戳 + const deletedCourse = await prisma.course.update({ + where: { id: courseId }, + data: { + deletedAt: new Date(), + } as any, + }); + + // 从所有运营位移除该课程关联 + await prisma.operationalBannerCourse.deleteMany({ where: { courseId } }); + + logger.info(`课程已删除: ${courseId} - ${course.title}`); + + res.json({ + success: true, + data: { + course: { + id: deletedCourse.id, + title: deletedCourse.title, + }, + }, + }); + } catch (error) { + next(error); + } +}; + + +/** + * 发布/取消发布课程 + * PATCH /api/courses/:courseId/publish + */ +export const publishCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + const { status } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!status || (status !== 'published' && status !== 'draft' && status !== 'test_published')) { + throw new CustomError('发布状态必须是 published、draft 或 test_published', 400); + } + + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + const updatedCourse = await prisma.course.update({ + where: { id: courseId }, + data: { + status: status, + } as any, + }); + + logger.info(`课程状态已更新: ${courseId} - ${course.title} -> ${status}`); + + res.json({ + success: true, + data: { + course: { + id: updatedCourse.id, + title: updatedCourse.title, + status: (updatedCourse as any).status, + }, + }, + }); + } catch (error) { + next(error); + } +}; + + +/** + * 恢复已删除的课程 + * PATCH /api/courses/:courseId/restore + */ +export const restoreCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 检查是否已删除 + if (!(course as any).deletedAt) { + throw new CustomError('课程未被删除,无需恢复', 400); + } + + // 恢复:清除 deletedAt + const restoredCourse = await prisma.course.update({ + where: { id: courseId }, + data: { + deletedAt: null, + } as any, + }); + + logger.info(`课程已恢复: ${courseId} - ${course.title}`); + + res.json({ + success: true, + data: { + course: { + id: restoredCourse.id, + title: restoredCourse.title, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取已删除的课程列表 + * GET /api/courses/deleted + */ +export const getDeletedCourses = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 获取所有已删除的课程 + const courses = await prisma.course.findMany({ + where: { + deletedAt: { not: null }, // 只返回已删除的课程 + } as any, + include: { + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + orderBy: { deletedAt: 'desc' } as any, // 按删除时间倒序 + }); + + // 格式化课程数据(简化版,不需要学习进度) + const formattedCourses = courses.map((course: any) => { + const allNodes = course.nodes; + const coverImageUrl = course.coverImage + ? (course.coverImage.startsWith('http') + ? course.coverImage + : `${process.env.SERVER_URL || process.env.API_BASE_URL || 'https://api.muststudy.xin'}/${course.coverImage}`) + : ''; + + return { + id: course.id, + title: course.title, + description: course.description || '', + cover_image: coverImageUrl, + type: 'system', // ✅ 简化:所有课程都是竖屏课程 + publish_status: course.status || 'published', + total_nodes: course.totalNodes, + deleted_at: course.deletedAt, + }; + }); + + res.json({ + success: true, + data: { + courses: formattedCourses, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 生成课程封面图 + * POST /api/courses/:courseId/generate-cover + */ +export const generateCourseCover = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // ✅ 支持强制重新生成:如果请求参数中有 force=true,即使已有封面也重新生成 + const forceRegenerate = req.query.force === 'true'; + + if (course.coverImage && !forceRegenerate) { + return res.json({ + success: true, + data: { + coverImage: course.coverImage, + }, + }); + } + + // 生成封面图(渐变背景,无标题) + const { generateCourseCover: generateCover } = await import('../services/coverImageService'); + const coverImagePath = await generateCover(course.id, course.title, 'full'); + + if (!coverImagePath) { + throw new CustomError('封面图生成失败', 500); + } + + // 更新课程的封面图 + await prisma.course.update({ + where: { id: courseId }, + data: { coverImage: coverImagePath }, + }); + + res.json({ + success: true, + data: { + coverImage: coverImagePath, + }, + }); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/backend/src/controllers/learningController.ts b/backend/src/controllers/learningController.ts new file mode 100644 index 0000000..18ca31b --- /dev/null +++ b/backend/src/controllers/learningController.ts @@ -0,0 +1,911 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { AuthRequest } from '../middleware/auth'; +import { logger } from '../utils/logger'; + +/** + * 将HTML内容转换为纯文本 + */ +function htmlToPlainText(html: string): string { + if (!html) return ''; + + // 创建一个临时div来解析HTML + // 注意:在Node.js环境中,我们需要使用简单的字符串替换 + // 因为Node.js没有DOM API,这里使用正则表达式处理 + + let text = html; + + // 移除HTML标签,但保留文本内容 + text = text.replace(/<[^>]+>/g, ''); + + // 解码HTML实体 + text = text + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); + + // 将多个连续空格/换行符合并为单个 + text = text.replace(/\s+/g, ' '); + + // 移除首尾空格 + text = text.trim(); + + return text; +} + +/** + * 获取节点内容(幻灯片数据) + * GET /api/lessons/{nodeId}/content + */ +export const getNodeContent = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; // 可能为 undefined(游客模式) + const { nodeId } = req.params; + + // 获取节点信息 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + slides: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // ✅ 检查节点是否有内容(幻灯片) + // 如果节点存在但没有幻灯片,可能是内容还在生成中 + if (!node.slides || node.slides.length === 0) { + // 检查课程是否正在生成中 + const course = await prisma.course.findUnique({ + where: { id: node.courseId }, + select: { id: true }, + }); + + // ✅ AI 生成状态检查已删除 + } + + // ✅ 游客模式:只返回节点内容,不检查用户和会员权限 + if (!userId) { + // 格式化幻灯片数据 + const slides = node.slides.map((slide) => { + const content = slide.content as any; + + const formattedContent: any = {}; + + if (content.title) formattedContent.title = content.title; + if (content.paragraphs) formattedContent.paragraphs = content.paragraphs; + if (content.imageUrl) formattedContent.image_url = content.imageUrl; + if (content.highlightKeywords) formattedContent.highlight_keywords = content.highlightKeywords; + if (content.imagePosition) formattedContent.image_position = content.imagePosition; + + return { + id: slide.id, + type: slide.slideType, + order: slide.orderIndex, + content: formattedContent, + effect: slide.effect || null, + interaction: slide.interaction || null, + }; + }); + + return res.json({ + success: true, + data: { + node_id: node.id, + node_title: node.title, + total_slides: slides.length, + slides, + }, + }); + } + + // ✅ 登录模式:保持原有逻辑(包含调试日志) + // 检查用户是否存在(但不检查 isPro) + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new CustomError('用户不存在', 404); + } + + // 【当前版本:完全免费】移除会员权限检查 + // 原逻辑:非会员用户前 2 个节点可访问,第 3 个节点及以后需要会员 + // 新逻辑:所有节点都可以访问 + // if (!user.isPro && node.orderIndex > 2) { + // throw new CustomError('需要会员权限才能访问此内容', 403); + // } + + // 格式化幻灯片数据 + const slides = node.slides.map((slide) => { + // 转换 content 字段名:将驼峰命名转换为蛇形命名(匹配前端期望) + const content = slide.content as any; + + // 过滤空标签 + if (content.paragraphs && Array.isArray(content.paragraphs)) { + content.paragraphs = content.paragraphs.filter((para: string) => { + if (typeof para !== 'string') return true; + return !para.includes('') && !(para.includes('')); + }); + } + + const formattedContent: any = {}; + + if (content.title) formattedContent.title = content.title; + if (content.paragraphs) formattedContent.paragraphs = content.paragraphs; + if (content.imageUrl) formattedContent.image_url = content.imageUrl; // 转换字段名 + if (content.highlightKeywords) formattedContent.highlight_keywords = content.highlightKeywords; + if (content.imagePosition) formattedContent.image_position = content.imagePosition; + + return { + id: slide.id, + type: slide.slideType, + order: slide.orderIndex, + content: formattedContent, + effect: slide.effect || null, + interaction: slide.interaction || null, + }; + }); + + res.json({ + success: true, + data: { + node_id: node.id, + node_title: node.title, + total_slides: slides.length, + slides, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 开始学习节点 + * POST /api/lessons/{nodeId}/start + */ +export const startLesson = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 检查节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 【当前版本:完全免费】移除会员权限检查 + // 检查用户是否存在(但不检查 isPro) + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new CustomError('用户不存在', 404); + } + + // 【当前版本:完全免费】移除会员权限检查 + // 原逻辑:非会员用户前 2 个节点可学习,第 3 个节点及以后需要会员 + // 新逻辑:所有节点都可以学习 + // if (!user.isPro && node.orderIndex > 2) { + // throw new CustomError('需要会员权限才能学习此内容', 403); + // } + + // 检查是否已有学习进度 + const existingProgress = await prisma.userLearningProgress.findUnique({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + }); + + // 如果节点已是 completed,直接返回(不重复写入) + if (existingProgress?.status === 'completed') { + res.json({ + success: true, + data: { + started_at: existingProgress.startedAt?.toISOString(), + }, + }); + return; + } + + // 打开页面即视为完成:创建或更新为 completed + const now = new Date(); + const progress = await prisma.userLearningProgress.upsert({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + update: { + status: 'completed', + completionRate: 100, + completedAt: now, + startedAt: existingProgress?.startedAt || now, + }, + create: { + userId, + nodeId, + status: 'completed', + startedAt: now, + completedAt: now, + completionRate: 100, + }, + }); + + res.json({ + success: true, + data: { + started_at: progress.startedAt?.toISOString(), + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 上报学习进度 + * POST /api/lessons/{nodeId}/progress + */ +export const updateProgress = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + const { current_slide, study_time } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (typeof current_slide !== 'number' || current_slide < 0) { + throw new CustomError('current_slide 必须是大于等于 0 的数字', 400); + } + + if (typeof study_time !== 'number' || study_time < 0) { + throw new CustomError('study_time 必须是大于等于 0 的数字', 400); + } + + // 检查学习进度是否存在 + const progress = await prisma.userLearningProgress.findUnique({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + }); + + if (!progress) { + throw new CustomError('请先开始学习此节点', 400); + } + + // 【核心修复】如果节点已经是 completed 状态,拒绝更新进度 + // 已完成节点视为永久状态,不允许再次修改 + if (progress.status === 'completed') { + // 返回成功但不更新数据库,保持 completed 状态不变 + res.json({ + success: true, + }); + return; + } + + // 获取节点总幻灯片数,计算完成度 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + slides: true, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + const totalSlides = node.slides.length; + const completionRate = + totalSlides > 0 + ? Math.floor((current_slide / totalSlides) * 100) + : 0; + + // 更新进度 + await prisma.userLearningProgress.update({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + data: { + currentSlide: current_slide, + totalStudyTime: study_time, + completionRate: Math.min(completionRate, 100), + }, + }); + + res.json({ + success: true, + }); + } catch (error) { + next(error); + } +}; + +/** + * 上报竖屏课程学习进度(Float progress) + * POST /api/lessons/nodes/{nodeId}/progress + */ +export const updateVerticalLessonProgress = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + const { progress } = req.body; // Float 0.0-1.0 + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (typeof progress !== 'number' || progress < 0 || progress > 1) { + throw new CustomError('progress 必须是 0.0-1.0 之间的数字', 400); + } + + // 检查节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 【与幻灯片进度接口一致】若该用户该节点已是 completed,不再根据 progress < 1.0 降级为 in_progress + const existingProgress = await prisma.userLearningProgress.findUnique({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + }); + if (existingProgress?.status === 'completed') { + res.json({ success: true }); + return; + } + + // 将 Float progress (0.0-1.0) 转换为 completionRate (0-100) + const completionRate = Math.floor(progress * 100); + + // 查找或创建学习进度 + await prisma.userLearningProgress.upsert({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + update: { + completionRate: completionRate, + status: progress >= 1.0 ? 'completed' : 'in_progress', + ...(progress >= 1.0 && { + completedAt: new Date(), + }), + }, + create: { + userId, + nodeId, + completionRate: completionRate, + status: progress >= 1.0 ? 'completed' : 'in_progress', + startedAt: new Date(), + ...(progress >= 1.0 && { + completedAt: new Date(), + }), + }, + }); + + res.json({ + success: true, + }); + } catch (error) { + next(error); + } +}; + +/** + * 完成节点学习 + * POST /api/lessons/{nodeId}/complete + */ +export const completeLesson = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + const { total_study_time, completed_slides } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (typeof total_study_time !== 'number' || total_study_time < 0) { + throw new CustomError('total_study_time 必须是大于等于 0 的数字', 400); + } + + if (typeof completed_slides !== 'number' || completed_slides < 0) { + throw new CustomError('completed_slides 必须是大于等于 0 的数字', 400); + } + + // 检查学习进度是否存在 + let progress = await prisma.userLearningProgress.findUnique({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + }); + + // ✅ 新增:如果进度不存在,自动创建(支持"补单"逻辑) + // 用于游客学习完后登录,立即补发学习记录的场景 + if (!progress) { + const now = new Date(); + progress = await prisma.userLearningProgress.create({ + data: { + userId, + nodeId, + status: 'in_progress', // 先设为 in_progress,下面会更新为 completed + startedAt: now, // 使用当前时间作为开始时间 + totalStudyTime: 0, + currentSlide: 0, + completionRate: 0, + }, + }); + } + + // 获取节点信息,用于判断下一个节点(无论节点是否已完成,都需要查找下一节) + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + course: { + include: { + nodes: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // ✅ 如果节点已经是 completed 状态,拒绝更新(保持原子性,防止重复提交) + // 但仍然需要查找并返回下一节信息 + const isAlreadyCompleted = progress.status === 'completed'; + const now = new Date(); // 统一时间戳,用于后续操作 + + if (!isAlreadyCompleted) { + // 更新学习进度为已完成 + await prisma.userLearningProgress.update({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + data: { + status: 'completed', + completedAt: now, + totalStudyTime: total_study_time, + currentSlide: completed_slides, + completionRate: 100, + }, + }); + } + + // 查找下一个节点(无论节点是否已完成,都需要查找) + const nextNode = node.course.nodes.find( + (n) => n.orderIndex === node.orderIndex + 1 + ); + + // 如果下一个节点存在,且用户是会员或节点顺序 <= 2,则自动解锁 + let unlockedNext = false; + let nextNodeId: string | null = null; + + if (nextNode) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + // 【当前版本:完全免费】所有节点都自动解锁 + // 原逻辑:会员用户或前 2 个节点,自动解锁 + // 新逻辑:所有节点都自动解锁 + // if (user?.isPro || nextNode.orderIndex <= 2) { + if (true) { // 所有节点都解锁 + // 创建下一个节点的学习进度(如果不存在) + await prisma.userLearningProgress.upsert({ + where: { + userId_nodeId: { + userId, + nodeId: nextNode.id, + }, + }, + update: {}, + create: { + userId, + nodeId: nextNode.id, + status: 'not_started', + }, + }); + + unlockedNext = true; + nextNodeId = nextNode.id; + } + } + + // ✅ 如果节点已经是 completed 状态,直接返回(不创建成就记录,避免重复) + if (isAlreadyCompleted) { + return res.json({ + success: true, + data: { + unlocked_next: unlockedNext, + next_node_id: nextNodeId, + }, + }); + } + + // 创建成就记录(可选) + await prisma.userAchievement.create({ + data: { + userId, + achievementType: 'lesson_completed', + achievementData: { + nodeId, + nodeTitle: node.title, + completedAt: now.toISOString(), + }, + }, + }); + + res.json({ + success: true, + data: { + unlocked_next: unlockedNext, + next_node_id: nextNodeId, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取完成总结数据 + * GET /api/lessons/{nodeId}/summary + */ +export const getLessonSummary = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { nodeId } = req.params; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 获取节点信息 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + slides: true, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 获取学习进度 + const progress = await prisma.userLearningProgress.findUnique({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + }); + + if (!progress || progress.status !== 'completed') { + throw new CustomError('节点尚未完成', 400); + } + + // 计算用户已完成的总节点数(用于显示"学会的第X节") + const completedCount = await prisma.userLearningProgress.count({ + where: { + userId, + status: 'completed', + }, + }); + + // 计算学习时长(秒转分钟) + const studyTimeMinutes = Math.floor(progress.totalStudyTime / 60); + + res.json({ + success: true, + data: { + node_id: node.id, + node_title: node.title, + summary: { + lesson_number: completedCount, + study_time: studyTimeMinutes, + completion_rate: progress.completionRate, + total_slides: node.slides.length, + completed_slides: progress.currentSlide, + }, + achievement: { + unlocked: true, + badge: '🌱', + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取竖屏课程详情(用于竖屏课程播放器) + * GET /api/lessons/nodes/{nodeId}/detail + */ +export const getLessonDetail = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; // 可能为 undefined(游客模式) + const { nodeId } = req.params; + + // 获取节点信息 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + course: { + select: { + title: true, + description: true, + }, + }, + slides: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 转换 slides 为 blocks(竖屏课程使用 blocks 结构) + // 暂时从 slides 转换,后续可能需要专门的 blocks 数据 + const blocks: any[] = []; + + // ✅ 调试日志:记录节点和slides信息 + logger.info(`[getLessonDetail] 开始转换blocks: nodeId=${nodeId}, nodeTitle=${node.title}`); + logger.info(`[getLessonDetail] Slides数量: ${node.slides?.length || 0}`); + + // 如果有 slides,尝试转换为 blocks + if (node.slides && node.slides.length > 0) { + node.slides.forEach((slide, slideIndex) => { + const content = slide.content as any; + + // ✅ 调试日志:记录每个slide的内容格式 + logger.info(`[getLessonDetail] Slide[${slideIndex}]: id=${slide.id}, type=${slide.slideType}`); + logger.info(`[getLessonDetail] Slide[${slideIndex}] content keys: ${Object.keys(content || {}).join(', ')}`); + + // 检查是否是旧格式 + if (content && content.type === 'text' && content.text && !content.paragraphs) { + logger.warn(`[getLessonDetail] ⚠️ Slide[${slideIndex}] 使用旧格式 {type: 'text', text: '...'}, 需要转换`); + } + + // 根据 slideType 和 content 转换为 block + if (content.title) { + blocks.push({ + id: slide.id, + type: 'heading1', + content: content.title, + }); + logger.info(`[getLessonDetail] Slide[${slideIndex}] 添加title block`); + } + + if (content.paragraphs && Array.isArray(content.paragraphs)) { + logger.info(`[getLessonDetail] Slide[${slideIndex}] 找到 ${content.paragraphs.length} 个paragraphs`); + content.paragraphs.forEach((para: string, index: number) => { + // 判断段落类型 + let blockType = 'paragraph'; + if (para.trim().startsWith('>')) { + blockType = 'quote'; + } else if (para.includes('**') || para.includes('*')) { + // 可能包含高亮或粗体 + blockType = 'paragraph'; + } + + blocks.push({ + id: `${slide.id}-para-${index}`, + type: blockType, + content: para, + }); + }); + } else { + logger.warn(`[getLessonDetail] ⚠️ Slide[${slideIndex}] 没有paragraphs字段或不是数组`); + } + }); + } else { + logger.warn(`[getLessonDetail] ⚠️ 节点没有slides`); + } + + // ✅ 调试日志:记录最终blocks数量 + logger.info(`[getLessonDetail] 转换完成: 共生成 ${blocks.length} 个blocks`); + + // 如果没有 blocks,添加一些默认内容 + if (blocks.length === 0) { + logger.warn(`[getLessonDetail] ⚠️ 没有blocks,添加默认内容`); + blocks.push({ + id: 'default-1', + type: 'paragraph', + content: '内容正在准备中...', + }); + } + + // ✅ 查询用户的学习进度(如果已登录) + let lastProgress: number | null = null; + if (userId) { + const userProgress = await prisma.userLearningProgress.findUnique({ + where: { + userId_nodeId: { + userId, + nodeId, + }, + }, + select: { + completionRate: true, + }, + }); + + // 将 completionRate (0-100) 转换为 Float (0.0-1.0) + if (userProgress) { + lastProgress = userProgress.completionRate / 100.0; + } + } + + // 返回符合前端期望的数据结构 + res.json({ + success: true, + data: { + node_id: node.id, + title: node.title, + author: '得到用户', // 暂时使用默认值,后续可能需要从课程或用户数据获取 + intro: node.subtitle || node.course.description || '欢迎来到我的产品实战课。', // 使用 subtitle 或课程描述 + blocks: blocks, + last_progress: lastProgress, // ✅ 新增:上次阅读进度 (0.0-1.0) + }, + }); + } catch (error) { + next(error); + } +}; + + +/** + * 获取节点纯文本内容(用于系统笔记管理) + * GET /api/lessons/{nodeId}/plain-text + */ +export const getNodePlainText = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { nodeId } = req.params; + + // 获取节点信息 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + include: { + slides: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 将所有幻灯片内容转换为纯文本 + const textParts: string[] = []; + + for (const slide of node.slides) { + const content = slide.content as any; + + // 添加标题 + if (content.title) { + textParts.push(htmlToPlainText(content.title)); + } + + // 添加段落 + if (content.paragraphs && Array.isArray(content.paragraphs)) { + for (const para of content.paragraphs) { + if (para && typeof para === 'string') { + const plainText = htmlToPlainText(para); + if (plainText.trim()) { + textParts.push(plainText); + } + } + } + } + } + + // 合并所有文本,用换行符分隔 + const plainText = textParts.join('\n'); + const length = plainText.length; + + res.json({ + success: true, + data: { + plainText, + length, + }, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/myCoursesController.ts b/backend/src/controllers/myCoursesController.ts new file mode 100644 index 0000000..0efa6b8 --- /dev/null +++ b/backend/src/controllers/myCoursesController.ts @@ -0,0 +1,442 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { logger } from '../utils/logger'; +import { AuthRequest } from '../middleware/auth'; + +/** + * 加入课程 + * POST /api/my-courses/join + */ +export const joinCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!courseId) { + throw new CustomError('课程ID不能为空', 400); + } + + // 检查课程是否存在 + const course = await prisma.course.findUnique({ + where: { id: courseId }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 检查是否已经加入 + const existing = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + }); + + if (existing) { + throw new CustomError('已经加入该课程', 400); + } + + // 加入课程 + await prisma.userCourse.create({ + data: { + userId, + courseId, + }, + }); + + logger.info({ + message: '用户加入课程成功', + userId, + courseId, + }); + + res.json({ + success: true, + message: '已加入课程', + }); + } catch (error) { + logger.error({ + message: '加入课程失败', + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId: req.userId, + courseId: req.body?.courseId, + }); + next(error); + } +}; + +/** + * 移除课程 + * POST /api/my-courses/remove + */ +export const removeCourse = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!courseId) { + throw new CustomError('课程ID不能为空', 400); + } + + // ✅ 统一逻辑:检查是否已加入(通过 UserCourse) + const existing = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + include: { + course: { + select: { + createdBy: true, + deletedAt: true, + }, + }, + }, + }); + + if (!existing) { + throw new CustomError('未加入该课程', 404); + } + + // ✅ 删除 UserCourse 记录 + await prisma.userCourse.delete({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + }); + + // ✅ 如果是用户创建的课程,同时软删除课程 + const course = existing.course as any; + if (course.createdBy === userId && !course.deletedAt) { + await prisma.course.update({ + where: { id: courseId }, + data: { + deletedAt: new Date(), + } as any, + }); + logger.info(`用户删除自己创建的课程: userId=${userId}, courseId=${courseId}`); + } + + res.json({ + success: true, + message: '已移除课程', + }); + } catch (error) { + logger.error({ + message: '移除课程失败', + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId: req.userId, + courseId: req.body?.courseId, + }); + next(error); + } +}; + +/** + * 检查课程是否已加入 + * POST /api/my-courses/check + */ +export const checkIsJoined = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!courseId) { + throw new CustomError('课程ID不能为空', 400); + } + + // 检查是否已加入 + const existing = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + }); + + res.json({ + success: true, + data: !!existing, + }); + } catch (error) { + logger.error({ + message: '检查加入状态失败', + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId: req.userId, + courseId: req.body?.courseId, + }); + next(error); + } +}; + +/** + * 获取用户已加入的课程列表 + * GET /api/my-courses + * 返回:课程信息 + 进度状态 + 最近打开时间(按最近打开时间倒序) + */ +export const getJoinedCourses = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // ✅ 只查询用户已加入的课程(通过 UserCourse) + // 用户创建的课程也会自动创建 UserCourse 记录,所以这里统一查询即可 + const userCourses = await prisma.userCourse.findMany({ + where: { + userId, + course: { + deletedAt: null, // 排除已删除的课程 + }, + }, + include: { + course: { + include: { + nodes: { + select: { + id: true, + }, + }, + generationTask: { + select: { + id: true, // ✅ 新增:返回 taskId + status: true, + progress: true, + errorMessage: true, + }, + }, + }, + }, + }, + orderBy: [ + { + lastOpenedAt: 'desc', // 优先按最近打开时间倒序 + }, + { + createdAt: 'desc', // 如果 lastOpenedAt 为 null,按加入时间倒序 + }, + ], + }); + + // ✅ 简化逻辑:只返回 UserCourse 关联的课程 + const filteredUserCourses = userCourses; + + // 查询用户的学习进度,用于计算课程状态 + const allNodeIds = filteredUserCourses.flatMap(uc => (uc.course.nodes || []).map(n => n.id)); + const progressRecords = await prisma.userLearningProgress.findMany({ + where: { + userId, + nodeId: { + in: allNodeIds, + }, + }, + }); + + // 构建节点ID到进度的映射 + const progressMap = new Map( + progressRecords.map(p => [p.nodeId, p]) + ); + + // 处理每个课程,计算状态和进度 + const coursesWithStatus = filteredUserCourses.map((uc) => { + const course = uc.course; + const generationTask = (course as any).generationTask; + const nodeIds = course.nodes?.map(n => n.id) || []; // ✅ 安全处理 nodes + const nodeProgresses = nodeIds + .map((nodeId: string) => progressMap.get(nodeId)) + .filter((p): p is typeof progressRecords[0] => Boolean(p)); + + // ✅ 判断是否在生成中 + const isGenerating = generationTask && + ['pending', 'content_generating'].includes(generationTask.status); + + // 计算课程状态(复用现有的课程状态计算逻辑) + const totalNodes = nodeIds.length; + const completedNodes = nodeProgresses.filter( + (p: typeof progressRecords[0]) => p.status === 'completed' + ).length; + const inProgressNodes = nodeProgresses.filter( + (p: typeof progressRecords[0]) => p.status === 'in_progress' + ).length; + + // ✅ 如果课程正在生成中且还没有节点,totalNodes 为 0,这是正常的 + + let courseStatus: string; + if (isGenerating) { + // 如果正在生成,使用生成状态 + courseStatus = generationTask.status === 'completed' ? 'completed' : + generationTask.status === 'failed' ? 'failed' : 'generating'; + } else if (completedNodes === totalNodes && totalNodes > 0) { + courseStatus = 'completed'; + } else if (inProgressNodes > 0 || completedNodes > 0) { + courseStatus = 'in_progress'; + } else { + courseStatus = 'not_started'; + } + + // 计算进度百分比 + let progress: number; + if (isGenerating) { + // 如果正在生成,使用生成进度 + progress = generationTask.progress || 0; + } else { + // 否则使用学习进度 + progress = totalNodes > 0 + ? Math.round((completedNodes / totalNodes) * 100) + : 0; + } + + return { + id: course.id, + title: course.title, + description: course.description, + cover_image: course.coverImage, + type: course.type, + is_portrait: course.isPortrait, // ✅ 新增:是否为竖屏课程 + status: courseStatus, + progress: progress, + total_nodes: totalNodes, + completed_nodes: completedNodes, + last_opened_at: uc.lastOpenedAt?.toISOString() || null, + joined_at: uc.createdAt.toISOString(), + created_by: (course as any).createdBy || null, // ✅ 课程创建者ID,用于判断是否可以修改 + is_generating: isGenerating, + generation_status: generationTask?.status || null, + generation_progress: generationTask?.progress || null, + generation_task_id: generationTask?.id || null, // ✅ 新增:返回 taskId + }; + }).map((c: any) => { + // ✅ 移除临时ID前缀(如果有) + const courseId = typeof c.id === 'string' && c.id.startsWith('created_') + ? c.id.replace('created_', '') + : c.id; + return { + ...c, + id: courseId, + }; + }); + + res.json({ + success: true, + data: { + courses: coursesWithStatus, + }, + }); + } catch (error) { + logger.error({ + message: '获取已加入课程列表失败', + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId: req.userId, + }); + next(error); + } +}; + +/** + * 更新课程最近打开时间 + * POST /api/my-courses/update-last-opened + * 当用户打开课程详情页时调用此接口 + */ +export const updateLastOpened = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { courseId } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!courseId) { + throw new CustomError('课程ID不能为空', 400); + } + + // 检查是否已加入 + const existing = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + }); + + if (!existing) { + throw new CustomError('未加入该课程', 404); + } + + // 更新最近打开时间 + await prisma.userCourse.update({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + data: { + lastOpenedAt: new Date(), + }, + }); + + res.json({ + success: true, + message: '已更新最近打开时间', + }); + } catch (error) { + logger.error({ + message: '更新最近打开时间失败', + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId: req.userId, + courseId: req.body?.courseId, + }); + next(error); + } +}; diff --git a/backend/src/controllers/noteController.ts b/backend/src/controllers/noteController.ts new file mode 100644 index 0000000..331fe9b --- /dev/null +++ b/backend/src/controllers/noteController.ts @@ -0,0 +1,1019 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { AuthRequest } from '../middleware/auth'; +import { logger } from '../utils/logger'; +import { SYSTEM_USER_ID } from '../constants'; + +/** + * 创建笔记 + * POST /api/notes + */ +export const createNote = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { + // ✅ Phase 2: 新增笔记本和层级字段 + notebook_id, + parent_id, + order, + level, + // 现有字段(现在为可选) + course_id, + node_id, + type, + content, + quoted_text, + start_index, + length, + style + } = req.body; + + // ✅ Phase 2: 验证笔记类型(必填) + if (!type) { + throw new CustomError('笔记类型不能为空', 400); + } + + // 验证笔记类型 + if (!['highlight', 'thought', 'comment'].includes(type)) { + throw new CustomError('无效的笔记类型', 400); + } + + // ✅ MVP 版本:所有笔记必须关联到课程笔记本 + // 如果 course_id 不存在,报错 + if (!course_id) { + throw new CustomError('MVP 版本要求所有笔记必须关联到课程,请提供 course_id', 400); + } + + // 获取课程信息(包含封面) + const course = await prisma.course.findUnique({ + where: { id: course_id }, + select: { + title: true, + coverImage: true, // ✅ 新增:获取课程封面 + }, + }); + + if (!course) { + throw new CustomError('课程不存在', 404); + } + + // 确定笔记本:优先使用提供的 notebook_id,否则使用课程笔记本 + let finalNotebookId = notebook_id; + + if (!finalNotebookId) { + // 如果没有提供 notebook_id,查找或创建课程笔记本 + const notebookTitle = `《${course.title}》`; + + // 查找是否有同名笔记本 + let courseNotebook = await prisma.notebook.findFirst({ + where: { + userId, + title: notebookTitle, + }, + }); + + // 如果没有,自动创建 + if (!courseNotebook) { + // 检查用户是否已有50个笔记本(限制) + const notebookCount = await prisma.notebook.count({ + where: { userId }, + }); + + if (notebookCount >= 50) { + throw new CustomError('每个用户最多可创建50个笔记本', 400); + } + + // ✅ 新增:创建笔记本时,将课程封面复制到笔记本封面 + courseNotebook = await prisma.notebook.create({ + data: { + userId, + title: notebookTitle, + description: `自动创建的笔记本,用于存放"${course.title}"课程的笔记`, + coverImage: course.coverImage, // ✅ 使用课程封面 + }, + }); + + logger.info(`✅ 自动创建课程笔记本: ${courseNotebook.id} (${notebookTitle})`, { + userId, + courseId: course_id, + hasCoverImage: !!course.coverImage, + }); + } else { + // ✅ 新增:如果笔记本已存在但没有封面,更新封面 + if (!courseNotebook.coverImage && course.coverImage) { + courseNotebook = await prisma.notebook.update({ + where: { id: courseNotebook.id }, + data: { + coverImage: course.coverImage, + }, + }); + + logger.info(`✅ 更新课程笔记本封面: ${courseNotebook.id}`, { + userId, + courseId: course_id, + }); + } + } + + finalNotebookId = courseNotebook.id; + } + + // ✅ Phase 2: 验证笔记本(现在 finalNotebookId 一定存在) + const notebook = await prisma.notebook.findFirst({ + where: { + id: finalNotebookId, + userId, // 确保只能使用自己的笔记本 + }, + }); + if (!notebook) { + throw new CustomError('笔记本不存在或无权访问', 404); + } + + // ✅ Phase 2: 验证父笔记(如果提供) + if (parent_id) { + const parentNote = await prisma.note.findFirst({ + where: { + id: parent_id, + userId, // 确保只能使用自己的笔记作为父笔记 + }, + }); + if (!parentNote) { + throw new CustomError('父笔记不存在或无权访问', 404); + } + // 验证层级(父笔记的层级 + 1 不能超过 2) + const parentLevel = parentNote.level || 0; + if (parentLevel >= 2) { + throw new CustomError('层级不能超过3层', 400); + } + } + + // ✅ Phase 2: 计算层级(如果未提供) + let noteLevel = 0; + if (level !== undefined) { + noteLevel = level; + } else if (parent_id) { + const parentNote = await prisma.note.findUnique({ + where: { id: parent_id }, + select: { level: true }, + }); + noteLevel = parentNote ? (parentNote.level || 0) + 1 : 0; + } + + // 验证层级(0-2) + if (noteLevel < 0 || noteLevel > 2) { + throw new CustomError('层级必须在0-2之间', 400); + } + + // ✅ MVP 版本:验证课程和节点(所有笔记必须关联课程) + // 注意:course 已经在上面获取过了,这里只需要验证 node_id + if (!node_id) { + throw new CustomError('MVP 版本要求所有笔记必须关联到课程节点,请提供 node_id', 400); + } + + const node = await prisma.courseNode.findUnique({ + where: { id: node_id }, + }); + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // 验证节点是否属于该课程 + if (node.courseId !== course_id) { + throw new CustomError('节点不属于该课程', 400); + } + + // 验证:定位信息(全局 NSRange)- 课程笔记必须有 + if (start_index === undefined || start_index === null || length === undefined || length === null) { + throw new CustomError('课程笔记必须提供 start_index 和 length', 400); + } + + if (start_index < 0 || length <= 0) { + throw new CustomError('start_index 必须为非负整数,length 必须为正整数', 400); + } + + // 验证:课程笔记必须有 quoted_text + if (!quoted_text) { + throw new CustomError('课程笔记必须提供 quoted_text', 400); + } + + // ✅ Phase 2: 验证:想法笔记必须有 content + if (type === 'thought' && !content) { + throw new CustomError('想法笔记内容不能为空', 400); + } + + // ✅ Phase 2: 计算 order(如果未提供,使用同级最大 order + 1) + let noteOrder = order !== undefined ? order : 0; + if (order === undefined && finalNotebookId) { + const maxOrderNote = await prisma.note.findFirst({ + where: { + notebookId: finalNotebookId, + parentId: parent_id || null, + }, + orderBy: { order: 'desc' }, + }); + noteOrder = maxOrderNote ? (maxOrderNote.order || 0) + 1 : 0; + } + + // ✅ Phase 2: 验证笔记本笔记数量限制(每个笔记本最多50条) + if (finalNotebookId) { + const noteCount = await prisma.note.count({ + where: { notebookId: finalNotebookId }, + }); + if (noteCount >= 50) { + throw new CustomError('每个笔记本最多可包含50条笔记', 400); + } + } + + // 创建笔记 + const note = await prisma.note.create({ + data: { + userId, + // ✅ Phase 2: 新增字段(使用 finalNotebookId,可能来自自动创建) + notebookId: finalNotebookId, // ✅ 现在 finalNotebookId 一定存在(已自动关联到默认笔记本) + parentId: parent_id || null, + order: noteOrder, + level: noteLevel, + // 现有字段(现在为可选) + courseId: course_id || null, + nodeId: node_id || null, + startIndex: start_index || null, + length: length || null, + type, + content: content || '', + quotedText: quoted_text || null, + style: style || (type === 'highlight' ? 'default' : null), // 默认值:highlight 为 'default',thought 为 null + }, + }); + + // 获取用户信息(用于返回用户名) + const currentUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { nickname: true }, + }); + + // 返回笔记数据(包含关联信息) + res.json({ + success: true, + data: { + id: note.id, + user_id: note.userId, + user_name: currentUser?.nickname || null, + // ✅ Phase 2: 新增字段 + notebook_id: note.notebookId, + parent_id: note.parentId, + order: note.order, + level: note.level, + // 现有字段 + course_id: note.courseId, + node_id: note.nodeId, + start_index: note.startIndex, + length: note.length, + type: note.type, + content: note.content, + quoted_text: note.quotedText, + style: note.style, + created_at: note.createdAt.toISOString(), + updated_at: note.updatedAt.toISOString(), + course_title: course?.title || null, + node_title: node?.title || null, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取笔记列表 + * GET /api/notes + */ +export const getNotes = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { + course_id, + node_id, + notebook_id, // ✅ Phase 2: 新增按笔记本过滤 + type, + system_only, // ✅ 系统笔记管理:只返回系统笔记 + page = '1', + limit = '20' + } = req.query; + + // 构建查询条件 + // ✅ 系统笔记功能: + // - 如果 system_only=true,只返回系统笔记(用于管理后台) + // - 如果查询 nodeId,则返回用户笔记 + 系统笔记(用于播放器页面) + // - 如果查询 notebookId,则只返回用户笔记(系统笔记在笔记本中不可见) + // - 其他情况,只返回用户笔记 + let where: any; + + if (system_only === 'true') { + // ✅ 系统笔记管理:只返回系统笔记 + where = { + userId: SYSTEM_USER_ID, + }; + if (node_id) { + where.nodeId = node_id as string; + } + } else if (node_id) { + // ✅ 播放器页面:返回用户笔记 + 系统笔记 + where = { + nodeId: node_id as string, + OR: [ + { userId }, // 用户自己的笔记 + { userId: SYSTEM_USER_ID }, // 系统笔记 + ], + }; + } else { + // ✅ 其他情况:只返回用户自己的笔记(系统笔记的 userId 是 SYSTEM_USER_ID,不会包含在内) + where = { + userId, // 只能查看自己的笔记 + }; + } + + if (course_id) { + where.courseId = course_id as string; + } + + // ✅ Phase 2: 按笔记本过滤 + if (notebook_id) { + where.notebookId = notebook_id as string; + } + + if (type && ['highlight', 'thought', 'comment'].includes(type as string)) { + where.type = type as string; + } + + // 分页 + const pageNum = parseInt(page as string, 10) || 1; + const limitNum = parseInt(limit as string, 10) || 20; + const skip = (pageNum - 1) * limitNum; + + // 查询笔记 + // ✅ 修正:先查询所有有效的 nodeId,然后只查询这些笔记 + // 这样可以避免 Prisma 查询时 node 为 null 的错误 + const validNodeIds = await prisma.courseNode.findMany({ + select: { id: true }, + }); + const validNodeIdSet = new Set(validNodeIds.map((n) => n.id)); + + // 只查询 nodeId 有效的笔记 + // ✅ 修正:如果 where 中已经有 nodeId,需要验证它是否有效;否则添加过滤条件 + // ⚠️ 重要:如果按 notebook_id 查询,不应该过滤 nodeId,因为笔记本中的笔记可能没有 nodeId(独立笔记) + const whereWithValidNode: any = { + ...where, + }; + + // 如果 where 中已经有 nodeId,验证它是否有效 + if (where.nodeId) { + if (!validNodeIdSet.has(where.nodeId as string)) { + // 如果指定的 nodeId 无效,返回空结果 + return res.json({ + success: true, + data: { + notes: [], + total: 0, + }, + }); + } + } else if (!notebook_id) { + // ✅ 修复:只有在没有指定 notebook_id 时,才过滤 nodeId + // 如果指定了 notebook_id,说明是查询笔记本的笔记,可能包含没有 nodeId 的独立笔记 + // 如果没有指定 nodeId,只查询有效的 nodeId(用于课程笔记列表) + whereWithValidNode.nodeId = { + in: Array.from(validNodeIdSet), + }; + } + // 如果指定了 notebook_id,不添加 nodeId 过滤,允许查询 nodeId 为 null 的笔记 + + // ✅ Phase 2: 排序逻辑 + // 如果按 notebook_id 过滤,使用层级排序(按 order,相同 order 按 createdAt 升序) + // 如果按 node_id 过滤,需要用户笔记在前,系统笔记在后(通过分别查询实现) + // 否则使用默认排序(按 createdAt 降序) + let notes: any[]; + let total: number; + + if (node_id) { + // ✅ 播放器页面:分别查询用户笔记和系统笔记,然后合并(用户笔记在前) + // 先查询count,再查询notes(避免在Promise.all中引用未定义的变量) + const [userCount, systemCount] = await Promise.all([ + prisma.note.count({ + where: { + nodeId: node_id as string, + userId, + ...(course_id ? { courseId: course_id as string } : {}), + ...(type && ['highlight', 'thought', 'comment'].includes(type as string) ? { type: type as string } : {}), + }, + }), + prisma.note.count({ + where: { + nodeId: node_id as string, + userId: SYSTEM_USER_ID, + ...(course_id ? { courseId: course_id as string } : {}), + ...(type && ['highlight', 'thought', 'comment'].includes(type as string) ? { type: type as string } : {}), + }, + }), + ]); + + // 然后查询notes(先查询用户笔记,再查询系统笔记) + const userNotes = await prisma.note.findMany({ + where: { + nodeId: node_id as string, + userId, // 只查询用户笔记 + ...(course_id ? { courseId: course_id as string } : {}), + ...(type && ['highlight', 'thought', 'comment'].includes(type as string) ? { type: type as string } : {}), + }, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + orderBy: { createdAt: 'asc' as const }, + skip, + take: limitNum, + }); + + // 查询系统笔记(使用剩余空间) + const remainingSpace = Math.max(0, limitNum - userNotes.length); + const systemNotes = remainingSpace > 0 ? await prisma.note.findMany({ + where: { + nodeId: node_id as string, + userId: SYSTEM_USER_ID, // 只查询系统笔记 + ...(course_id ? { courseId: course_id as string } : {}), + ...(type && ['highlight', 'thought', 'comment'].includes(type as string) ? { type: type as string } : {}), + }, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + orderBy: { createdAt: 'asc' as const }, + skip: 0, + take: remainingSpace, + }) : []; + + // 合并:用户笔记在前,系统笔记在后 + notes = [...userNotes, ...systemNotes]; + total = userCount + systemCount; + } else { + // 其他情况:正常查询 + const orderBy = notebook_id + ? [ + { order: 'asc' as const }, + { createdAt: 'asc' as const }, // 升序:新笔记在底部 + ] + : { createdAt: 'desc' as const }; + + [notes, total] = await Promise.all([ + prisma.note.findMany({ + where: whereWithValidNode, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + orderBy, + skip, + take: limitNum, + }), + prisma.note.count({ where: whereWithValidNode }), + ]); + } + + // 格式化返回数据 + const formattedNotes = notes.map((note) => ({ + id: note.id, + user_id: note.userId, + user_name: note.user?.nickname || null, + // ✅ Phase 2: 新增字段 + notebook_id: note.notebookId, + parent_id: note.parentId, + order: note.order, + level: note.level, + // 现有字段 + course_id: note.courseId, + node_id: note.nodeId, + start_index: note.startIndex, + length: note.length, + type: note.type, + content: note.content, + quoted_text: note.quotedText, + style: note.style, + created_at: note.createdAt.toISOString(), + updated_at: note.updatedAt.toISOString(), + course_title: note.course ? note.course.title : null, + node_title: note.node ? note.node.title : null, + })); + + res.json({ + success: true, + data: { + notes: formattedNotes, + total, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取单个笔记 + * GET /api/notes/:noteId + */ +export const getNote = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { noteId } = req.params; + + const note = await prisma.note.findUnique({ + where: { id: noteId }, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + }); + + if (!note) { + throw new CustomError('笔记不存在', 404); + } + + // 验证权限:只能查看自己的笔记 + if (note.userId !== userId) { + throw new CustomError('无权访问', 403); + } + + res.json({ + success: true, + data: { + id: note.id, + user_id: note.userId, + user_name: note.user.nickname || null, + course_id: note.courseId, + node_id: note.nodeId, + start_index: note.startIndex, + length: note.length, + type: note.type, + content: note.content, + quoted_text: note.quotedText, + style: note.style, + created_at: note.createdAt.toISOString(), + updated_at: note.updatedAt.toISOString(), + course_title: note.course ? note.course.title : null, + node_title: note.node ? note.node.title : null, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新笔记 + * PUT /api/notes/:noteId + */ +export const updateNote = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { noteId } = req.params; + const { content, quoted_text, start_index, length, style } = req.body; + + // 查找笔记 + const note = await prisma.note.findUnique({ + where: { id: noteId }, + }); + + if (!note) { + throw new CustomError('笔记不存在', 404); + } + + // ✅ 系统笔记管理:允许编辑系统笔记(系统笔记的userId是SYSTEM_USER_ID) + // 验证权限:只能编辑自己的笔记或系统笔记 + if (note.userId !== userId && note.userId !== SYSTEM_USER_ID) { + throw new CustomError('无权编辑', 403); + } + + // 如果是系统笔记,需要验证用户是否有权限(暂时允许所有登录用户,后续可以添加管理员权限检查) + // TODO: 添加管理员权限检查 + + // 验证:想法笔记必须有 content + if (note.type === 'thought' && content !== undefined && !content) { + throw new CustomError('想法笔记内容不能为空', 400); + } + + // 验证:如果更新定位信息,需要验证 + if (start_index !== undefined || length !== undefined) { + const finalStartIndex = start_index !== undefined ? start_index : note.startIndex; + const finalLength = length !== undefined ? length : note.length; + + if (finalStartIndex < 0 || finalLength <= 0) { + throw new CustomError('start_index 必须为非负整数,length 必须为正整数', 400); + } + } + + // 更新笔记 + const updatedNote = await prisma.note.update({ + where: { id: noteId }, + data: { + content: content !== undefined ? content : note.content, + quotedText: quoted_text !== undefined ? quoted_text : note.quotedText, + startIndex: start_index !== undefined ? start_index : note.startIndex, + length: length !== undefined ? length : note.length, + style: style !== undefined ? (style || null) : note.style, + }, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + }); + + res.json({ + success: true, + data: { + id: updatedNote.id, + user_id: updatedNote.userId, + user_name: updatedNote.user.nickname || null, + course_id: updatedNote.courseId, + node_id: updatedNote.nodeId, + start_index: updatedNote.startIndex, + length: updatedNote.length, + type: updatedNote.type, + content: updatedNote.content, + quoted_text: updatedNote.quotedText, + style: updatedNote.style, + created_at: updatedNote.createdAt.toISOString(), + updated_at: updatedNote.updatedAt.toISOString(), + course_title: updatedNote.course ? updatedNote.course.title : null, + node_title: updatedNote.node ? updatedNote.node.title : null, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 删除笔记 + * DELETE /api/notes/:noteId + */ +export const deleteNote = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { noteId } = req.params; + + // 查找笔记 + const note = await prisma.note.findUnique({ + where: { id: noteId }, + }); + + if (!note) { + throw new CustomError('笔记不存在', 404); + } + + // ✅ 系统笔记管理:允许删除系统笔记(系统笔记的userId是SYSTEM_USER_ID) + // 验证权限:只能删除自己的笔记或系统笔记 + if (note.userId !== userId && note.userId !== SYSTEM_USER_ID) { + throw new CustomError('无权删除', 403); + } + + // 如果是系统笔记,需要验证用户是否有权限(暂时允许所有登录用户,后续可以添加管理员权限检查) + // TODO: 添加管理员权限检查 + + // 删除笔记 + await prisma.note.delete({ + where: { id: noteId }, + }); + + res.json({ + success: true, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取节点下的所有笔记(用于在课程内容中显示) + * GET /api/lessons/:nodeId/notes + */ +export const getNodeNotes = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { nodeId } = req.params; + + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: nodeId }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + // ✅ 系统笔记功能:查询该节点下的所有笔记(包括用户笔记和系统笔记) + // 优先返回用户笔记,然后返回系统笔记 + const [userNotes, systemNotes] = await Promise.all([ + // 用户自己的笔记 + prisma.note.findMany({ + where: { + userId, + nodeId, + }, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }), + // 系统笔记 + prisma.note.findMany({ + where: { + userId: SYSTEM_USER_ID, + nodeId, + }, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }), + ]); + + // ✅ 合并:用户笔记在前,系统笔记在后 + const notes = [...userNotes, ...systemNotes]; + + // 格式化返回数据(包含所有必需字段,与 getNotes 接口保持一致) + const formattedNotes = notes.map((note) => ({ + id: note.id, + user_id: note.userId, + user_name: note.user?.nickname || null, + // ✅ 新增字段(与 getNotes 接口保持一致) + notebook_id: note.notebookId, + parent_id: note.parentId, + order: note.order, + level: note.level, + // 现有字段 + course_id: note.courseId, + node_id: note.nodeId, + start_index: note.startIndex, + length: note.length, + type: note.type, + content: note.content, + quoted_text: note.quotedText, + style: note.style, + created_at: note.createdAt.toISOString(), + updated_at: note.updatedAt.toISOString(), + course_title: note.course ? note.course.title : null, + node_title: note.node ? note.node.title : null, + })); + + res.json({ + success: true, + data: { + notes: formattedNotes, + total: formattedNotes.length, // ✅ 添加 total 字段,与 NoteListData 结构匹配 + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 创建系统笔记(管理员权限) + * POST /api/notes/system + */ +export const createSystemNote = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + // ✅ 系统笔记功能:暂时允许所有登录用户创建系统笔记(后续可以添加管理员权限检查) + // TODO: 添加管理员权限检查 + // if (!req.isAdmin) { + // throw new CustomError('无权限创建系统笔记', 403); + // } + + const { + course_id, + node_id, + type, + content, + quoted_text, + start_index, + length, + style + } = req.body; + + // 验证笔记类型 + if (!type) { + throw new CustomError('笔记类型不能为空', 400); + } + + if (!['highlight', 'thought', 'comment'].includes(type)) { + throw new CustomError('无效的笔记类型', 400); + } + + // 验证必填字段 + if (!course_id || !node_id) { + throw new CustomError('course_id 和 node_id 不能为空', 400); + } + + // 验证节点是否存在 + const node = await prisma.courseNode.findUnique({ + where: { id: node_id }, + include: { + course: true, + }, + }); + + if (!node) { + throw new CustomError('节点不存在', 404); + } + + if (node.courseId !== course_id) { + throw new CustomError('节点不属于指定课程', 400); + } + + // ✅ 确保系统用户存在(如果不存在则创建) + let systemUser = await prisma.user.findUnique({ + where: { id: SYSTEM_USER_ID }, + }); + + if (!systemUser) { + try { + systemUser = await prisma.user.create({ + data: { + id: SYSTEM_USER_ID, + nickname: '系统', + agreementAccepted: true, + isPro: false, + }, + }); + logger.info('系统用户已自动创建'); + } catch (error: any) { + // 如果创建失败(可能是并发创建),再次尝试查找 + systemUser = await prisma.user.findUnique({ + where: { id: SYSTEM_USER_ID }, + }); + if (!systemUser) { + throw new CustomError('无法创建系统用户', 500); + } + } + } + + // 创建系统笔记 + const note = await prisma.note.create({ + data: { + userId: SYSTEM_USER_ID, // ✅ 使用系统用户ID + courseId: course_id, + nodeId: node_id, + startIndex: start_index || null, + length: length || null, + type, + content: content || null, + quotedText: quoted_text || null, + style: style || null, + }, + include: { + course: { + select: { title: true }, + }, + node: { + select: { title: true }, + }, + user: { + select: { nickname: true }, + }, + }, + }); + + // 格式化返回数据 + const formattedNote = { + id: note.id, + user_id: note.userId, + user_name: note.user?.nickname || null, + course_id: note.courseId, + node_id: note.nodeId, + start_index: note.startIndex, + length: note.length, + type: note.type, + content: note.content, + quoted_text: note.quotedText, + style: note.style, + created_at: note.createdAt.toISOString(), + updated_at: note.updatedAt.toISOString(), + course_title: note.course ? note.course.title : null, + node_title: note.node ? note.node.title : null, + }; + + res.json({ + success: true, + data: formattedNote, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/notebookController.ts b/backend/src/controllers/notebookController.ts new file mode 100644 index 0000000..d413141 --- /dev/null +++ b/backend/src/controllers/notebookController.ts @@ -0,0 +1,496 @@ +import { Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { AuthRequest } from '../middleware/auth'; +import { logger } from '../utils/logger'; + +/** + * 创建笔记本 + * POST /api/notebooks + */ +export const createNotebook = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { title, description, cover_image } = req.body; + + // 验证必填字段 + if (!title || title.trim() === '') { + throw new CustomError('笔记本名称不能为空', 400); + } + + // 验证名称长度(1-50字符) + const trimmedTitle = title.trim(); + if (trimmedTitle.length < 1 || trimmedTitle.length > 50) { + throw new CustomError('笔记本名称长度必须在1-50字符之间', 400); + } + + // 验证描述长度(0-200字符) + if (description && description.length > 200) { + throw new CustomError('描述长度不能超过200字符', 400); + } + + // 检查用户是否已有50个笔记本(限制) + const notebookCount = await prisma.notebook.count({ + where: { userId }, + }); + + if (notebookCount >= 50) { + throw new CustomError('每个用户最多可创建50个笔记本', 400); + } + + // ✅ 自动获取课程封面(如果未提供封面) + let finalCoverImage = cover_image || null; + + if (!finalCoverImage) { + // 方法1:从笔记本标题中提取课程名称,尝试匹配课程 + const courseName = trimmedTitle + .replace(/《/g, '') + .replace(/》/g, '') + .trim(); + + if (courseName) { + // 先尝试精确匹配 + let matchingCourse = await prisma.course.findFirst({ + where: { title: courseName }, + select: { id: true, title: true, coverImage: true }, + }); + + // 如果精确匹配失败,尝试包含匹配 + if (!matchingCourse) { + matchingCourse = await prisma.course.findFirst({ + where: { + title: { + contains: courseName, + }, + }, + select: { id: true, title: true, coverImage: true }, + }); + } + + if (matchingCourse?.coverImage) { + finalCoverImage = matchingCourse.coverImage; + logger.info(`[Notebook] createNotebook: 从课程 "${matchingCourse.title}" 自动获取封面: ${finalCoverImage}`); + } + } + } + + // 创建笔记本 + const notebook = await prisma.notebook.create({ + data: { + userId, + title: trimmedTitle, + description: description ? description.trim() : null, + coverImage: finalCoverImage, // ✅ 使用自动获取的封面 + }, + }); + + res.json({ + success: true, + data: { + notebook: { + id: notebook.id, + user_id: notebook.userId, + title: notebook.title, + description: notebook.description, + cover_image: notebook.coverImage, + created_at: notebook.createdAt, + updated_at: notebook.updatedAt, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取用户的所有笔记本 + * GET /api/notebooks + * ✅ 新增:自动从关联笔记的课程中获取封面(如果笔记本封面为空) + */ +export const getNotebooks = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + // 获取所有笔记本,并计算每个笔记本的笔记数量 + const notebooks = await prisma.notebook.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + include: { + _count: { + select: { notes: true }, + }, + }, + }); + + // ✅ 为每个笔记本自动获取课程封面(如果封面为空) + logger.info(`[Notebook] getNotebooks: 找到 ${notebooks.length} 个笔记本`); + const notebooksWithCount = await Promise.all( + notebooks.map(async (notebook) => { + let coverImage = notebook.coverImage; + + // ✅ 如果笔记本封面为空,尝试从关联笔记的课程中获取 + const conditionResult = !coverImage || (coverImage && coverImage.trim() === ''); + if (conditionResult) { + logger.info(`[Notebook] ${notebook.id}: 封面为空,开始尝试获取封面...`); + // ✅ 方法1:从关联笔记的课程中获取 + const firstNote = await prisma.note.findFirst({ + where: { + notebookId: notebook.id, + courseId: { not: null }, + }, + orderBy: { createdAt: 'asc' }, + include: { + course: { + select: { + coverImage: true, + }, + }, + }, + }); + + if (firstNote?.course?.coverImage) { + coverImage = firstNote.course.coverImage; + logger.info(`[Notebook] ${notebook.id}: 从课程 ${firstNote.courseId} 获取封面: ${coverImage}`); + } else if (firstNote?.courseId) { + // 备用方案:单独查询课程 + const course = await prisma.course.findUnique({ + where: { id: firstNote.courseId }, + select: { coverImage: true }, + }); + + if (course?.coverImage) { + coverImage = course.coverImage; + logger.info(`[Notebook] ${notebook.id}: 从课程 ${firstNote.courseId} 获取封面(备用方案): ${coverImage}`); + } + } + + // ✅ 方法2:如果方法1失败,尝试通过课程名称匹配(处理历史数据问题) + if (!coverImage) { + // 从笔记本标题中提取课程名称(移除书名号) + const courseName = notebook.title + .replace(/《/g, '') + .replace(/》/g, '') + .trim(); + + logger.info(`[Notebook] ${notebook.id}: 尝试通过课程名称匹配: "${courseName}"`); + + // ✅ 改进匹配逻辑:先尝试精确匹配(不要求封面),再尝试包含匹配 + let matchingCourse = await prisma.course.findFirst({ + where: { + title: courseName, // 精确匹配 + }, + select: { + id: true, + title: true, + coverImage: true, + }, + }); + + // 如果精确匹配失败,尝试包含匹配 + if (!matchingCourse) { + matchingCourse = await prisma.course.findFirst({ + where: { + title: { + contains: courseName, + }, + }, + select: { + id: true, + title: true, + coverImage: true, + }, + }); + } + + if (matchingCourse) { + logger.info(`[Notebook] ${notebook.id}: 找到匹配的课程: ${matchingCourse.title} (ID: ${matchingCourse.id})`); + + if (matchingCourse.coverImage) { + coverImage = matchingCourse.coverImage; + logger.info(`[Notebook] ${notebook.id}: ✅ 使用课程封面: ${coverImage}`); + } else { + // ✅ 备用方案:对于竖屏课程,尝试从课程的第一个节点获取封面 + // 或者使用默认封面逻辑(暂时保持null,让前端使用默认封面) + logger.warn(`[Notebook] ${notebook.id}: ⚠️ 课程 ${matchingCourse.title} 没有封面,将使用默认封面`); + } + } else { + logger.warn(`[Notebook] ${notebook.id}: ❌ 课程名称匹配失败: "${courseName}" - 未找到匹配的课程`); + // ✅ 调试:记录无法获取封面的原因 + const noteCount = await prisma.note.count({ + where: { notebookId: notebook.id }, + }); + const noteWithCourseId = await prisma.note.count({ + where: { + notebookId: notebook.id, + courseId: { not: null }, + }, + }); + if (noteCount > 0) { + logger.warn(`[Notebook] ${notebook.id}: 有 ${noteCount} 条笔记(${noteWithCourseId} 条有 courseId),无法获取封面`); + } else { + logger.warn(`[Notebook] ${notebook.id}: 没有笔记`); + } + } + } + } + + return { + id: notebook.id, + user_id: notebook.userId, + title: notebook.title, + description: notebook.description, + cover_image: coverImage, + note_count: notebook._count.notes, + created_at: notebook.createdAt, + updated_at: notebook.updatedAt, + }; + }) + ); + + res.json({ + success: true, + data: { + notebooks: notebooksWithCount, + total: notebooksWithCount.length, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取单个笔记本详情 + * GET /api/notebooks/:notebookId + * ✅ 新增:自动从关联笔记的课程中获取封面(如果笔记本封面为空) + */ +export const getNotebook = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { notebookId } = req.params; + + const notebook = await prisma.notebook.findFirst({ + where: { + id: notebookId, + userId, // 确保只能访问自己的笔记本 + }, + include: { + _count: { + select: { notes: true }, + }, + }, + }); + + if (!notebook) { + throw new CustomError('笔记本不存在', 404); + } + + // ✅ 如果笔记本封面为空,尝试从关联笔记的课程中获取 + let coverImage = notebook.coverImage; + if (!coverImage || coverImage.trim() === '') { + // ✅ 优化:使用 include 一次性获取笔记和课程信息 + const firstNote = await prisma.note.findFirst({ + where: { + notebookId: notebook.id, + courseId: { not: null }, + }, + orderBy: { createdAt: 'asc' }, + include: { + course: { + select: { + coverImage: true, + }, + }, + }, + }); + + // ✅ 如果找到有课程关联的笔记,直接使用关联的课程封面 + if (firstNote?.course?.coverImage) { + coverImage = firstNote.course.coverImage; + } else if (firstNote?.courseId) { + // ✅ 备用方案:如果 include 失败,单独查询课程 + const course = await prisma.course.findUnique({ + where: { id: firstNote.courseId }, + select: { coverImage: true }, + }); + + if (course?.coverImage) { + coverImage = course.coverImage; + } + } + } + + res.json({ + success: true, + data: { + notebook: { + id: notebook.id, + user_id: notebook.userId, + title: notebook.title, + description: notebook.description, + cover_image: coverImage, // ✅ 使用自动获取的封面或原有封面 + note_count: notebook._count.notes, + created_at: notebook.createdAt, + updated_at: notebook.updatedAt, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新笔记本 + * PUT /api/notebooks/:notebookId + */ +export const updateNotebook = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { notebookId } = req.params; + const { title, description, cover_image } = req.body; + + // 检查笔记本是否存在且属于当前用户 + const existingNotebook = await prisma.notebook.findFirst({ + where: { + id: notebookId, + userId, + }, + }); + + if (!existingNotebook) { + throw new CustomError('笔记本不存在', 404); + } + + // 构建更新数据 + const updateData: any = {}; + if (title !== undefined) { + const trimmedTitle = title.trim(); + if (trimmedTitle.length < 1 || trimmedTitle.length > 50) { + throw new CustomError('笔记本名称长度必须在1-50字符之间', 400); + } + updateData.title = trimmedTitle; + } + if (description !== undefined) { + if (description && description.length > 200) { + throw new CustomError('描述长度不能超过200字符', 400); + } + updateData.description = description ? description.trim() : null; + } + if (cover_image !== undefined) { + updateData.coverImage = cover_image || null; + } + + // 更新笔记本 + const updatedNotebook = await prisma.notebook.update({ + where: { id: notebookId }, + data: updateData, + include: { + _count: { + select: { notes: true }, + }, + }, + }); + + res.json({ + success: true, + data: { + notebook: { + id: updatedNotebook.id, + user_id: updatedNotebook.userId, + title: updatedNotebook.title, + description: updatedNotebook.description, + cover_image: updatedNotebook.coverImage, + note_count: updatedNotebook._count.notes, + created_at: updatedNotebook.createdAt, + updated_at: updatedNotebook.updatedAt, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 删除笔记本(级联删除笔记) + * DELETE /api/notebooks/:notebookId + */ +export const deleteNotebook = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + if (!userId) { + throw new CustomError('需要登录', 401); + } + + const { notebookId } = req.params; + + // 检查笔记本是否存在且属于当前用户 + const existingNotebook = await prisma.notebook.findFirst({ + where: { + id: notebookId, + userId, + }, + include: { + _count: { + select: { notes: true }, + }, + }, + }); + + if (!existingNotebook) { + throw new CustomError('笔记本不存在', 404); + } + + // 删除笔记本(级联删除笔记,由 Prisma 的 onDelete: Cascade 处理) + await prisma.notebook.delete({ + where: { id: notebookId }, + }); + + res.json({ + success: true, + message: '笔记本已删除', + data: { + deleted_notes_count: existingNotebook._count.notes, + }, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/operationalBannerController.ts b/backend/src/controllers/operationalBannerController.ts new file mode 100644 index 0000000..f7a63d2 --- /dev/null +++ b/backend/src/controllers/operationalBannerController.ts @@ -0,0 +1,302 @@ +import { Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { AuthRequest } from '../middleware/auth'; +import { getStringParam } from '../utils/requestHelpers'; + +const SERVER_URL = process.env.SERVER_URL || process.env.API_BASE_URL || 'https://api.muststudy.xin'; +const MAX_COURSES_PER_BANNER = 10; + +function toCoverImageUrl(coverImage: string | null): string { + if (!coverImage) return ''; + if (coverImage.startsWith('http://') || coverImage.startsWith('https://')) { + return coverImage.includes('localhost:3000') ? coverImage.replace('http://localhost:3000', SERVER_URL) : coverImage; + } + const clean = coverImage.startsWith('/') ? coverImage.slice(1) : coverImage; + return `${SERVER_URL}/${clean}`; +} + +/** 公开:发现页拉取运营位,只返回启用、未删、且课程为公开的 */ +export const listPublic = async (_req: AuthRequest, res: Response, next: NextFunction) => { + try { + const banners = await prisma.operationalBanner.findMany({ + where: { deletedAt: null, isEnabled: true }, + orderBy: { orderIndex: 'asc' }, + include: { + courses: { + orderBy: { orderIndex: 'asc' }, + include: { + course: { + select: { + id: true, + title: true, + coverImage: true, + themeColor: true, + watermarkIcon: true, + deletedAt: true, + visibility: true, + }, + }, + }, + }, + }, + }); + + const data = banners.map((b) => { + const items = b.courses + .filter((obc) => obc.course && !obc.course.deletedAt && obc.course.visibility === 'public') + .slice(0, MAX_COURSES_PER_BANNER) + .map((obc) => ({ + id: obc.course!.id, + title: obc.course!.title, + cover_image: toCoverImageUrl(obc.course!.coverImage), + theme_color: obc.course!.themeColor ?? null, + watermark_icon: obc.course!.watermarkIcon ?? null, + })); + return { id: b.id, title: b.title, courses: items }; + }); + + res.json({ success: true, data: { banners: data } }); + } catch (error) { + next(error); + } +}; + +/** 管理:列表(含禁用、软删) */ +export const listAdmin = async (_req: AuthRequest, res: Response, next: NextFunction) => { + try { + const banners = await prisma.operationalBanner.findMany({ + orderBy: [{ deletedAt: 'asc' }, { orderIndex: 'asc' }], + include: { + courses: { + orderBy: { orderIndex: 'asc' }, + include: { + course: { + select: { id: true, title: true, coverImage: true, deletedAt: true, visibility: true }, + }, + }, + }, + }, + }); + + const data = banners.map((b) => ({ + id: b.id, + title: b.title, + orderIndex: b.orderIndex, + isEnabled: b.isEnabled, + deletedAt: b.deletedAt?.toISOString() ?? null, + courses: b.courses.map((obc) => ({ + courseId: obc.courseId, + orderIndex: obc.orderIndex, + title: obc.course?.title, + cover_image: obc.course ? toCoverImageUrl(obc.course.coverImage) : null, + deleted: !!obc.course?.deletedAt, + visibility: obc.course?.visibility, + })), + })); + + res.json({ success: true, data: { banners: data } }); + } catch (error) { + next(error); + } +}; + +/** 管理:创建 */ +export const create = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { title, orderIndex = 1, isEnabled = false } = req.body; // 新建运营位默认不启用 + if (!title || typeof title !== 'string' || title.trim() === '') { + throw new CustomError('标题不能为空', 400); + } + const order = typeof orderIndex === 'number' ? orderIndex : parseInt(String(orderIndex), 10) || 1; + + const banner = await prisma.operationalBanner.create({ + data: { title: title.trim(), orderIndex: order, isEnabled: !!isEnabled }, + }); + res.status(201).json({ success: true, data: { banner: { id: banner.id, title: banner.title, orderIndex: banner.orderIndex, isEnabled: banner.isEnabled } } }); + } catch (error) { + next(error); + } +}; + +/** 管理:更新 */ +export const update = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const id = getStringParam(req.params.id); + const { title, orderIndex, isEnabled } = req.body; + + const updateData: { title?: string; orderIndex?: number; isEnabled?: boolean } = {}; + if (typeof title === 'string' && title.trim() !== '') updateData.title = title.trim(); + if (typeof orderIndex === 'number') updateData.orderIndex = orderIndex; + if (typeof isEnabled === 'boolean') updateData.isEnabled = isEnabled; + + const banner = await prisma.operationalBanner.update({ + where: { id }, + data: updateData, + }); + res.json({ success: true, data: { banner: { id: banner.id, title: banner.title, orderIndex: banner.orderIndex, isEnabled: banner.isEnabled } } }); + } catch (error) { + next(error); + } +}; + +/** 管理:软删除 */ +export const remove = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const id = getStringParam(req.params.id); + await prisma.operationalBanner.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + res.json({ success: true }); + } catch (error) { + next(error); + } +}; + +/** 管理:批量更新排序 */ +export const updateOrder = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const items = req.body as Array<{ id: string; orderIndex: number }>; + if (!Array.isArray(items) || items.length === 0) { + throw new CustomError('body 需为 [{ id, orderIndex }, ...]', 400); + } + + await prisma.$transaction( + items.map(({ id, orderIndex }) => + prisma.operationalBanner.update({ + where: { id }, + data: { orderIndex: Number(orderIndex) || 1 }, + }) + ) + ); + res.json({ success: true }); + } catch (error) { + next(error); + } +}; + +/** 管理:批量向运营位添加课程 */ +export const addCoursesBatch = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const bannerId = getStringParam(req.params.id); + const { courses: items } = req.body as { courses?: Array<{ courseId: string; orderIndex?: number }> }; + if (!Array.isArray(items) || items.length === 0) { + throw new CustomError('body.courses 需为非空数组 [{ courseId, orderIndex? }, ...]', 400); + } + + const banner = await prisma.operationalBanner.findFirst({ + where: { id: bannerId, deletedAt: null }, + include: { courses: true }, + }); + if (!banner) throw new CustomError('运营位不存在或已删除', 404); + const currentCount = banner.courses.length; + if (currentCount + items.length > MAX_COURSES_PER_BANNER) { + throw new CustomError(`最多添加 ${MAX_COURSES_PER_BANNER - currentCount} 门,当前已有 ${currentCount} 门`, 400); + } + + const inBanner = new Set(banner.courses.map((c) => c.courseId)); + const toAdd: { courseId: string; orderIndex: number }[] = []; + for (let i = 0; i < items.length; i++) { + const { courseId, orderIndex } = items[i] || {}; + if (!courseId || typeof courseId !== 'string') continue; + if (inBanner.has(courseId)) continue; + const course = await prisma.course.findFirst({ + where: { id: courseId, deletedAt: null }, + }); + if (!course) continue; + inBanner.add(courseId); + const order = typeof orderIndex === 'number' ? orderIndex : currentCount + i + 1; + toAdd.push({ courseId, orderIndex: order }); + } + + if (toAdd.length === 0) { + throw new CustomError('没有可添加的课程(可能已存在或课程无效)', 400); + } + + await prisma.operationalBannerCourse.createMany({ + data: toAdd.map(({ courseId, orderIndex }) => ({ + bannerId, + courseId, + orderIndex, + })), + }); + res.status(201).json({ success: true, data: { added: toAdd.length } }); + } catch (error) { + next(error); + } +}; + +/** 管理:向运营位添加单门课程 */ +export const addCourse = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const bannerId = getStringParam(req.params.id); + const { courseId, orderIndex = 1 } = req.body; + if (!courseId) throw new CustomError('courseId 不能为空', 400); + + const banner = await prisma.operationalBanner.findFirst({ + where: { id: bannerId, deletedAt: null }, + include: { courses: true }, + }); + if (!banner) throw new CustomError('运营位不存在或已删除', 404); + if (banner.courses.length >= MAX_COURSES_PER_BANNER) { + throw new CustomError(`每个运营位最多 ${MAX_COURSES_PER_BANNER} 门课程`, 400); + } + + const course = await prisma.course.findFirst({ + where: { id: courseId, deletedAt: null }, + }); + if (!course) throw new CustomError('课程不存在或已删除', 404); + + const existing = await prisma.operationalBannerCourse.findUnique({ + where: { bannerId_courseId: { bannerId, courseId } }, + }); + if (existing) throw new CustomError('该课程已在此运营位中', 400); + + const order = typeof orderIndex === 'number' ? orderIndex : parseInt(String(orderIndex), 10) || 1; + await prisma.operationalBannerCourse.create({ + data: { bannerId, courseId, orderIndex: order }, + }); + res.status(201).json({ success: true }); + } catch (error) { + next(error); + } +}; + +/** 管理:从运营位移除课程 */ +export const removeCourse = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const bannerId = getStringParam(req.params.id); + const courseId = getStringParam(req.params.courseId); + + await prisma.operationalBannerCourse.deleteMany({ + where: { bannerId, courseId }, + }); + res.json({ success: true }); + } catch (error) { + next(error); + } +}; + +/** 管理:更新运营位内课程顺序 */ +export const updateCoursesOrder = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const bannerId = getStringParam(req.params.id); + const items = req.body as Array<{ courseId: string; orderIndex: number }>; + if (!Array.isArray(items) || items.length === 0) { + throw new CustomError('body 需为 [{ courseId, orderIndex }, ...]', 400); + } + + await prisma.$transaction( + items.map(({ courseId, orderIndex }) => + prisma.operationalBannerCourse.updateMany({ + where: { bannerId, courseId }, + data: { orderIndex: Number(orderIndex) || 1 }, + }) + ) + ); + res.json({ success: true }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/paymentController.ts b/backend/src/controllers/paymentController.ts new file mode 100644 index 0000000..8aa4fa1 --- /dev/null +++ b/backend/src/controllers/paymentController.ts @@ -0,0 +1,270 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { AuthRequest } from '../middleware/auth'; +import axios from 'axios'; +import { logger } from '../utils/logger'; + +/** + * Apple 收据验证接口 + * 生产环境:https://buy.itunes.apple.com/verifyReceipt + * 沙盒环境:https://sandbox.itunes.apple.com/verifyReceipt + */ +const APPLE_VERIFY_URL_PRODUCTION = 'https://buy.itunes.apple.com/verifyReceipt'; +const APPLE_VERIFY_URL_SANDBOX = 'https://sandbox.itunes.apple.com/verifyReceipt'; + +/** + * 验证 Apple 收据 + * POST /api/payment/verify-receipt + * + * 请求体: + * { + * "receipt_data": "base64编码的收据数据", + * "product_id": "com.wildgrowth.pro.monthly" + * } + */ +export const verifyReceipt = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + const { receipt_data, product_id } = req.body; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!receipt_data) { + throw new CustomError('receipt_data 不能为空', 400); + } + + if (!product_id) { + throw new CustomError('product_id 不能为空', 400); + } + + // 获取用户信息 + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new CustomError('用户不存在', 404); + } + + // 获取 Apple Shared Secret + const appleSharedSecret = process.env.APPLE_SHARED_SECRET; + + if (!appleSharedSecret || appleSharedSecret === '请从AppStoreConnect获取并填写') { + logger.warn('⚠️ APPLE_SHARED_SECRET 未配置,使用模拟验证模式'); + + // 开发/测试模式:如果未配置 Shared Secret,使用模拟验证 + return handleMockVerification(userId, product_id, res); + } + + // ========== 真实 Apple 验证流程 ========== + + logger.info('📦 开始验证 Apple 收据', { + userId, + productId: product_id, + receiptLength: receipt_data.length, + }); + + // 1. 先尝试生产环境验证 + let verificationResult = await verifyWithApple( + receipt_data, + appleSharedSecret, + APPLE_VERIFY_URL_PRODUCTION + ); + + // 2. 如果返回 21007 状态码,说明是沙盒收据,改用沙盒环境验证 + if (verificationResult.status === 21007) { + logger.info('🔄 检测到沙盒收据,切换到沙盒环境验证'); + verificationResult = await verifyWithApple( + receipt_data, + appleSharedSecret, + APPLE_VERIFY_URL_SANDBOX + ); + } + + // 3. 检查验证结果 + if (verificationResult.status !== 0) { + const errorMessage = getAppleStatusMessage(verificationResult.status); + logger.error('❌ Apple 收据验证失败', { + status: verificationResult.status, + message: errorMessage, + userId, + }); + throw new CustomError(`收据验证失败: ${errorMessage}`, 400); + } + + // 4. 解析收据信息 + const receipt = verificationResult.receipt; + const inAppPurchases = receipt.in_app || []; + + // 5. 查找匹配的产品交易 + const matchingTransaction = inAppPurchases.find( + (purchase: any) => purchase.product_id === product_id + ); + + if (!matchingTransaction) { + logger.error('❌ 未找到匹配的产品交易', { + productId: product_id, + availableProducts: inAppPurchases.map((p: any) => p.product_id), + }); + throw new CustomError(`未找到产品 ${product_id} 的交易记录`, 400); + } + + // 6. 验证交易状态 + // 注意:对于订阅产品,还需要检查 latest_receipt_info 中的最新交易 + const latestReceiptInfo = verificationResult.latest_receipt_info || []; + const latestTransaction = latestReceiptInfo.find( + (t: any) => t.product_id === product_id + ) || matchingTransaction; + + // 7. 检查是否已取消或退款 + if (latestTransaction.cancellation_date_ms) { + logger.warn('⚠️ 交易已取消', { + transactionId: latestTransaction.transaction_id, + cancellationDate: latestTransaction.cancellation_date_ms, + }); + throw new CustomError('该交易已取消', 400); + } + + // 8. 计算过期时间(对于订阅产品) + let expireDate: Date | null = null; + if (latestTransaction.expires_date_ms) { + expireDate = new Date(parseInt(latestTransaction.expires_date_ms)); + + // 检查是否已过期 + if (expireDate < new Date()) { + logger.warn('⚠️ 订阅已过期', { + transactionId: latestTransaction.transaction_id, + expireDate: expireDate.toISOString(), + }); + throw new CustomError('订阅已过期', 400); + } + } else { + // 非订阅产品(一次性购买),设置为永久会员或设置一个很远的过期时间 + const farFuture = new Date(); + farFuture.setFullYear(farFuture.getFullYear() + 100); + expireDate = farFuture; + } + + // 9. 更新用户会员状态 + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + isPro: true, + proExpireDate: expireDate, + }, + }); + + logger.info('✅ 收据验证成功,用户会员状态已更新', { + userId, + productId: product_id, + transactionId: latestTransaction.transaction_id, + expireDate: expireDate?.toISOString(), + }); + + res.json({ + success: true, + data: { + isPro: updatedUser.isPro, + proExpireDate: updatedUser.proExpireDate?.toISOString(), + transaction_id: latestTransaction.transaction_id, + original_transaction_id: latestTransaction.original_transaction_id, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 调用 Apple 验证服务器 + */ +async function verifyWithApple( + receiptData: string, + sharedSecret: string, + verifyUrl: string +): Promise { + try { + const response = await axios.post(verifyUrl, { + 'receipt-data': receiptData, + 'password': sharedSecret, + 'exclude-old-transactions': false, // 包含历史交易 + }, { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, // 10 秒超时 + }); + + return response.data; + } catch (error: any) { + logger.error('❌ Apple 验证服务器请求失败', { + error: error.message, + url: verifyUrl, + }); + throw new CustomError('无法连接到 Apple 验证服务器', 500); + } +} + +/** + * 获取 Apple 状态码对应的错误信息 + */ +function getAppleStatusMessage(status: number): string { + const statusMessages: { [key: number]: string } = { + 21000: 'App Store 无法读取你提供的 JSON 数据', + 21002: 'receipt-data 属性中的数据格式错误或丢失', + 21003: '收据无法通过验证', + 21004: '你提供的共享密钥与账户的共享密钥不匹配', + 21005: '收据服务器暂时无法使用', + 21006: '收据有效,但订阅已过期', + 21007: '收据来自沙盒环境,但发送到了生产环境', + 21008: '收据来自生产环境,但发送到了沙盒环境', + 21010: '收据无法授权', + }; + + return statusMessages[status] || `未知错误 (状态码: ${status})`; +} + +/** + * 模拟验证(用于开发/测试,当未配置 APPLE_SHARED_SECRET 时) + */ +async function handleMockVerification( + userId: string, + productId: string, + res: Response +) { + logger.warn('⚠️ 使用模拟验证模式(仅用于开发/测试)', { + userId, + productId, + }); + + const now = new Date(); + const expireDate = new Date(now); + expireDate.setFullYear(expireDate.getFullYear() + 1); // 一年后过期 + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + isPro: true, + proExpireDate: expireDate, + }, + }); + + const transactionId = `mock_${Date.now()}`; + + res.json({ + success: true, + data: { + isPro: updatedUser.isPro, + proExpireDate: updatedUser.proExpireDate?.toISOString(), + transaction_id: transactionId, + mock: true, // 标识这是模拟验证 + }, + }); +} diff --git a/backend/src/controllers/promptController.ts b/backend/src/controllers/promptController.ts new file mode 100644 index 0000000..91f77ff --- /dev/null +++ b/backend/src/controllers/promptController.ts @@ -0,0 +1,331 @@ +/** + * Prompt 管理控制器(V3.0) + * 支持多个Prompt的配置:生成模式 + 模型配置 + */ + +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { CustomError } from '../middleware/errorHandler'; +import { + getPromptTemplate, + setPromptTemplate, + resetPromptTemplate, + listBookAiCallLogs, + getBookAiCallLogById, + getModelConfig, + setModelConfig, + deleteModelConfig, + PromptType, + ModelConfigurablePromptType, +} from '../services/promptConfigService'; +import { DOUBAO_MODEL, DOUBAO_MODEL_LITE } from '../services/modelProvider'; +import { logger } from '../utils/logger'; +import { getStringParam } from '../utils/requestHelpers'; + +// Prompt类型定义 +export type PromptConfigType = + | 'course_title' // 课程标题生成Prompt + | 'course_summary' // 课程知识点摘要Prompt + | 'direct_generation_lite' // 直接测试-豆包lite + | 'direct_generation_lite_outline' // 直接测试-豆包lite-大纲 + | 'direct_generation_lite_summary' // 直接测试-豆包lite-总结 + | 'text_parse_xiaohongshu' // 文本解析-小红书 + | 'text_parse_xiaolin' // 文本解析-小林说 + | 'text_parse_douyin' // 文本解析-抖音 + | 'continue_course_xiaohongshu' // 续旧课-小红书 + | 'continue_course_xiaolin' // 续旧课-小林说 + | 'continue_course_douyin'; // 续旧课-抖音 + +// 支持模型配置的 Prompt 类型 +const MODEL_CONFIGURABLE_TYPES: ModelConfigurablePromptType[] = [ + 'direct_generation_lite', + 'direct_generation_lite_outline', + 'direct_generation_lite_summary', + 'text_parse_xiaohongshu', + 'text_parse_xiaolin', + 'text_parse_douyin', + 'continue_course_xiaohongshu', + 'continue_course_xiaolin', + 'continue_course_douyin', +]; + +/** + * 获取所有Prompt配置 + * GET /api/ai/prompts/v3 + */ +export const getAllPrompts = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const prompts: Record = {}; + const models: Record = {}; + + // 课程标题 + 摘要 Prompt + prompts.course_title = await getPromptTemplate('course_title'); + prompts.course_summary = await getPromptTemplate('course_summary'); + + // 直接生成的 3 个豆包 Lite Prompt + prompts.direct_generation_lite = await getPromptTemplate('direct_generation_lite'); + prompts.direct_generation_lite_outline = await getPromptTemplate('direct_generation_lite_outline'); + prompts.direct_generation_lite_summary = await getPromptTemplate('direct_generation_lite_summary'); + + // 获取文本解析的 3 个独立 Prompt + prompts.text_parse_xiaohongshu = await getPromptTemplate('text_parse_xiaohongshu'); + prompts.text_parse_xiaolin = await getPromptTemplate('text_parse_xiaolin'); + prompts.text_parse_douyin = await getPromptTemplate('text_parse_douyin'); + + // 获取续旧课的 3 个独立 Prompt + prompts.continue_course_xiaohongshu = await getPromptTemplate('continue_course_xiaohongshu'); + prompts.continue_course_xiaolin = await getPromptTemplate('continue_course_xiaolin'); + prompts.continue_course_douyin = await getPromptTemplate('continue_course_douyin'); + + // 获取可配置模型的 Prompt 的模型配置 + for (const type of MODEL_CONFIGURABLE_TYPES) { + models[type] = await getModelConfig(type); + } + + res.json({ + success: true, + data: { + prompts, + models, + availableModels: [ + { id: DOUBAO_MODEL, name: '豆包 Flash(默认)' }, + { id: DOUBAO_MODEL_LITE, name: '豆包 Lite' }, + ], + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取单个Prompt配置 + * GET /api/ai/prompts/v3/:type + */ +export const getPrompt = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const type = getStringParam(req.params.type) as PromptConfigType; + + if (!isValidPromptType(type)) { + throw new CustomError('无效的Prompt类型', 400); + } + + const template = await getPromptTemplate(type as PromptType); + + res.json({ + success: true, + data: { type, template }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 设置Prompt配置 + * PUT /api/ai/prompts/v3/:type + */ +export const setPrompt = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const type = getStringParam(req.params.type) as PromptConfigType; + const { template } = req.body; + + if (!isValidPromptType(type)) { + throw new CustomError('无效的Prompt类型', 400); + } + + if (!template || typeof template !== 'string' || template.trim().length === 0) { + throw new CustomError('模板不能为空', 400); + } + + await setPromptTemplate(type as PromptType, template.trim()); + + logger.info(`[PromptController] 已更新Prompt配置: type=${type}`); + + res.json({ + success: true, + message: 'Prompt配置已保存', + }); + } catch (error) { + next(error); + } +}; + +/** + * 重置Prompt配置为默认值 + * POST /api/ai/prompts/v3/:type/reset + */ +export const resetPrompt = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const type = getStringParam(req.params.type) as PromptConfigType; + + if (!isValidPromptType(type)) { + throw new CustomError('无效的Prompt类型', 400); + } + + await resetPromptTemplate(type as PromptType); + + logger.info(`[PromptController] 已重置Prompt配置: type=${type}`); + + res.json({ + success: true, + message: 'Prompt配置已重置为默认值', + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取调用日志列表 + * GET /api/ai/prompts/v3/logs + */ +export const getPromptLogs = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const taskId = req.query.taskId as string | undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 50; + const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : 0; + + const result = await listBookAiCallLogs({ taskId, limit, offset }); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取调用日志详情 + * GET /api/ai/prompts/v3/logs/:id + */ +export const getPromptLogDetail = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const id = getStringParam(req.params.id); + const log = await getBookAiCallLogById(id); + + if (!log) { + throw new CustomError('日志不存在', 404); + } + + res.json({ + success: true, + data: log, + }); + } catch (error) { + next(error); + } +}; + +/** + * 验证Prompt类型是否有效 + */ +function isValidPromptType(type: string): type is PromptConfigType { + return [ + 'course_title', + 'course_summary', + 'direct_generation_lite', + 'direct_generation_lite_outline', + 'direct_generation_lite_summary', + 'text_parse_xiaohongshu', + 'text_parse_xiaolin', + 'text_parse_douyin', + 'continue_course_xiaohongshu', + 'continue_course_xiaolin', + 'continue_course_douyin', + ].includes(type); +} + +/** + * 设置模型配置 + * PUT /api/ai/prompts/v3/:type/model + */ +export const setModel = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const type = getStringParam(req.params.type) as PromptConfigType; + const { modelId } = req.body; + + if (!MODEL_CONFIGURABLE_TYPES.includes(type as ModelConfigurablePromptType)) { + throw new CustomError('该 Prompt 类型不支持模型配置', 400); + } + + if (!modelId || typeof modelId !== 'string') { + throw new CustomError('模型 ID 不能为空', 400); + } + + // 验证模型 ID + if (modelId !== DOUBAO_MODEL && modelId !== DOUBAO_MODEL_LITE) { + throw new CustomError(`无效的模型 ID,必须是: ${DOUBAO_MODEL} 或 ${DOUBAO_MODEL_LITE}`, 400); + } + + await setModelConfig(type as ModelConfigurablePromptType, modelId); + + logger.info(`[PromptController] 已设置模型配置: type=${type}, modelId=${modelId}`); + + res.json({ + success: true, + message: '模型配置已保存', + }); + } catch (error) { + next(error); + } +}; + +/** + * 删除模型配置(恢复默认) + * DELETE /api/ai/prompts/v3/:type/model + */ +export const deleteModel = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const type = getStringParam(req.params.type) as PromptConfigType; + + if (!MODEL_CONFIGURABLE_TYPES.includes(type as ModelConfigurablePromptType)) { + throw new CustomError('该 Prompt 类型不支持模型配置', 400); + } + + await deleteModelConfig(type as ModelConfigurablePromptType); + + logger.info(`[PromptController] 已删除模型配置: type=${type}`); + + res.json({ + success: true, + message: '模型配置已删除,将使用默认模型', + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/uploadController.ts b/backend/src/controllers/uploadController.ts new file mode 100644 index 0000000..1f83633 --- /dev/null +++ b/backend/src/controllers/uploadController.ts @@ -0,0 +1,186 @@ +import { Request, Response, NextFunction } from 'express'; +import multer from 'multer'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { CustomError } from '../middleware/errorHandler'; +import { AuthRequest } from '../middleware/auth'; +import fs from 'fs'; +import { documentParserService } from '../services/documentParserService'; +import { logger } from '../utils/logger'; + +// 确保 images 目录存在 +const imagesDir = path.join(process.cwd(), 'public', 'images'); +if (!fs.existsSync(imagesDir)) { + fs.mkdirSync(imagesDir, { recursive: true }); +} + +// 确保 documents 目录存在(用于临时存储上传的文档) +const documentsDir = path.join(process.cwd(), 'public', 'documents'); +if (!fs.existsSync(documentsDir)) { + fs.mkdirSync(documentsDir, { recursive: true }); +} + +// 配置 multer 存储 +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, imagesDir); + }, + filename: (req, file, cb) => { + // 生成唯一文件名:UUID + 原始扩展名 + const ext = path.extname(file.originalname); + const filename = `${uuidv4()}${ext}`; + cb(null, filename); + }, +}); + +// 文件过滤器:只允许图片格式 +const fileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; + if (allowedMimes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new CustomError('不支持的图片格式,仅支持 JPG、PNG、WebP', 400)); + } +}; + +// 文档文件过滤器:允许 PDF、Word 和 EPUB +const documentFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + const allowedMimes = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx + 'application/msword', // .doc (虽然不支持,但先允许上传,后续提示转换) + 'application/epub+zip', // .epub + 'application/epub', // .epub (备用 MIME 类型) + ]; + const allowedExts = ['.pdf', '.docx', '.doc', '.epub']; + const ext = path.extname(file.originalname).toLowerCase(); + + if (allowedMimes.includes(file.mimetype) || allowedExts.includes(ext)) { + cb(null, true); + } else { + cb(new CustomError('不支持的文档格式,仅支持 PDF、Word (.docx) 和 EPUB', 400)); + } +}; + +// 配置 multer(图片上传) +const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: 2 * 1024 * 1024, // 2MB + }, +}); + +// 配置 multer(文档上传) +const documentStorage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, documentsDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + const filename = `${uuidv4()}${ext}`; + cb(null, filename); + }, +}); + +const documentUpload = multer({ + storage: documentStorage, + fileFilter: documentFileFilter, + limits: { + fileSize: 50 * 1024 * 1024, // 50MB + }, +}); + +/** + * 上传图片 + * POST /api/upload/image + */ +export const uploadImage = [ + upload.single('image'), + async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!req.file) { + throw new CustomError('请选择图片文件', 400); + } + + // 返回相对路径(简单格式) + const imageUrl = `images/${req.file.filename}`; + + res.json({ + success: true, + data: { + imageUrl, + filename: req.file.filename, + size: req.file.size, + }, + }); + } catch (error) { + next(error); + } + }, +]; + +/** + * 上传文档并提取文本 + * POST /api/upload/document + * 返回提取的文本内容,可直接用于 AI 课程生成 + */ +export const uploadDocument = [ + documentUpload.single('document'), + async (req: AuthRequest, res: Response, next: NextFunction) => { + let filePath: string | null = null; + + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!req.file) { + throw new CustomError('请选择文档文件', 400); + } + + filePath = req.file.path; + const mimeType = req.file.mimetype; + const originalName = req.file.originalname; + + logger.info(`[UploadController] 开始处理文档上传: ${originalName}, 大小: ${req.file.size} bytes`); + + // 解析文档,提取文本 + const extractedText = await documentParserService.extractText(filePath, mimeType); + + // 验证提取的文本长度 + if (extractedText.length === 0) { + throw new CustomError('文档未包含可提取的文本内容', 400); + } + + // 清理临时文件 + await documentParserService.cleanupFile(filePath); + filePath = null; + + logger.info(`[UploadController] 文档解析成功: ${originalName}, 提取文本长度: ${extractedText.length}`); + + res.json({ + success: true, + data: { + text: extractedText, + originalLength: extractedText.length, + filename: originalName, + }, + }); + } catch (error) { + // 确保清理临时文件 + if (filePath) { + await documentParserService.cleanupFile(filePath).catch(() => {}); + } + next(error); + } + }, +]; diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts new file mode 100644 index 0000000..2db801c --- /dev/null +++ b/backend/src/controllers/userController.ts @@ -0,0 +1,260 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../utils/prisma'; +import { CustomError } from '../middleware/errorHandler'; +import { logger } from '../utils/logger'; + +/** + * 获取用户信息 + * GET /api/user/profile + */ +import { AuthRequest } from '../middleware/auth'; + +export const getProfile = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + settings: true, + learningProgress: { + where: { + status: 'completed', + }, + }, + }, + }); + + if (!user) { + throw new CustomError('用户不存在', 404); + } + + // 计算总学习时长(分钟) + const totalStudyTime = user.learningProgress.reduce( + (sum, progress) => sum + progress.totalStudyTime, + 0 + ); + + // 计算已完成的课程节点数 + const completedLessons = user.learningProgress.length; + + res.json({ + success: true, + data: { + id: user.id, + phone: user.phone ? `${user.phone.slice(0, 3)}****${user.phone.slice(-4)}` : null, + nickname: user.nickname, + avatar: user.avatar, + isPro: user.isPro, + proExpireDate: user.proExpireDate, + total_study_time: Math.floor(totalStudyTime / 60), // 转换为分钟 + completed_lessons: completedLessons, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新用户资料(昵称和/或头像) + * PUT /api/user/profile + * body: { nickname?: string, avatar?: string },至少传一个;只更新传入的字段 + */ +export const updateProfile = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + const { nickname, avatar } = req.body; + + const updates: { nickname?: string; avatar?: string } = {}; + + if (nickname !== undefined) { + const trimmed = typeof nickname === 'string' ? nickname.trim() : ''; + if (trimmed.length === 0) { + throw new CustomError('昵称不能为空', 400); + } + if (trimmed.length > 50) { + throw new CustomError('昵称长度不能超过 50 个字符', 400); + } + updates.nickname = trimmed; + } + + if (avatar !== undefined) { + updates.avatar = typeof avatar === 'string' ? avatar.trim() || null : null; + } + + if (Object.keys(updates).length === 0) { + throw new CustomError('请提供 nickname 或 avatar', 400); + } + + const user = await prisma.user.update({ + where: { id: userId }, + data: updates, + }); + + res.json({ + success: true, + data: { + id: user.id, + nickname: user.nickname, + avatar: user.avatar ?? undefined, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 获取用户设置 + * GET /api/user/settings + */ +export const getSettings = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + let settings = await prisma.userSettings.findUnique({ + where: { userId }, + }); + + // 如果设置不存在,创建默认设置 + if (!settings) { + settings = await prisma.userSettings.create({ + data: { + userId, + pushNotification: true, + }, + }); + } + + res.json({ + success: true, + data: { + push_notification: settings.pushNotification, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * 更新用户设置 + * PUT /api/user/settings + */ +export const updateSettings = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + const { push_notification } = req.body; + + // 如果 push_notification 未提供,不更新 + if (push_notification === undefined) { + throw new CustomError('请提供要更新的设置项', 400); + } + + // 确保设置记录存在 + await prisma.userSettings.upsert({ + where: { userId }, + create: { + userId, + pushNotification: Boolean(push_notification), + }, + update: { + pushNotification: Boolean(push_notification), + }, + }); + + res.json({ + success: true, + message: '设置已更新', + }); + } catch (error) { + next(error); + } +}; + +/** + * 删除用户账号 + * DELETE /api/user/account + * + * 根据 Apple App Store 要求(2022年6月30日起),所有支持创建账户的应用 + * 必须在应用内提供账户删除功能。 + * + * 此接口会删除用户及其所有关联数据: + * - UserSettings (onDelete: Cascade) + * - UserLearningProgress (onDelete: Cascade) + * - UserAchievement (onDelete: Cascade) + */ +export const deleteAccount = async ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + // 验证用户是否存在 + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new CustomError('用户不存在', 404); + } + + // 删除用户(由于 Prisma schema 中配置了 onDelete: Cascade, + // 删除 User 时会自动删除所有关联数据) + await prisma.user.delete({ + where: { id: userId }, + }); + + logger.info(`用户 ${userId} 已删除账号`); + + res.json({ + success: true, + message: '账号已成功删除', + }); + } catch (error) { + logger.error(`删除账号失败: ${error}`); + next(error); + } +}; + diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e74b2a1 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,122 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import path from 'path'; +import { logger } from './utils/logger'; +import { errorHandler } from './middleware/errorHandler'; +import { initSMSService } from './services/smsService'; +import { initAppleAuthService } from './services/appleAuthService'; + +// Load environment variables +dotenv.config(); + +// 初始化短信服务 +initSMSService(); + +// 初始化 Apple 认证服务 +initAppleAuthService(); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +// ✅ 增加 JSON body 大小限制(支持最大 500KB,足够处理 100K 字符的内容) +app.use(express.json({ limit: '100mb' })); +app.use(express.urlencoded({ extended: true, limit: '100mb' })); + +// ✅ 捕获 body-parser 的 413 错误(请求体过大) +app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + if (err.type === 'entity.too.large' || err.status === 413) { + logger.warn(`[Request] 请求体过大: path=${req.path}, method=${req.method}, contentLength=${req.headers['content-length']}`); + return res.status(413).json({ + success: false, + error: { + message: '请求内容过长,请缩短后重试(建议不超过10万字)', + }, + }); + } + next(err); +}); + +// 静态文件服务(用于课程录入工具) +app.use(express.static('public')); + +// Request logging +app.use((req, res, next) => { + logger.info(`${req.method} ${req.path}`); + next(); +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', message: 'Wild Growth API is running' }); +}); + +// Support page route (for App Store Connect requirement) +app.get('/support', (req, res) => { + const supportPath = path.join(process.cwd(), 'public', 'support.html'); + res.sendFile(supportPath); +}); + +// Marketing/Homepage route (for App Store Connect Marketing URL) +app.get('/', (req, res) => { + const indexPath = path.join(process.cwd(), 'public', 'index.html'); + res.sendFile(indexPath); +}); + +// API Routes +import authRoutes from './routes/authRoutes'; +import userRoutes from './routes/userRoutes'; +import courseRoutes from './routes/courseRoutes'; +import learningRoutes from './routes/learningRoutes'; +import paymentRoutes from './routes/paymentRoutes'; +import uploadRoutes from './routes/uploadRoutes'; +import myCoursesRoutes from './routes/myCoursesRoutes'; +import noteRoutes from './routes/noteRoutes'; +import notebookRoutes from './routes/notebookRoutes'; +import aiCourseRoutes from './routes/aiCourseRoutes'; +import aiTopicsRoutes from './routes/aiTopicsRoutes'; +import aiThinkingFlowRoutes from './routes/aiThinkingFlowRoutes'; +import promptRoutes from './routes/promptRoutes'; +import operationalBannerRoutes, { adminRouter as operationalBannerAdminRoutes } from './routes/operationalBannerRoutes'; +import { adminCallRecordsRouter } from './routes/adminCallRecordsRoutes'; +import playgroundRoutes from './routes/playgroundRoutes'; +import analyticsRoutes from './routes/analyticsRoutes'; +import { authenticate, optionalAuthenticate } from './middleware/auth'; + +app.use('/api/auth', authRoutes); +app.use('/api/user', authenticate, userRoutes); +// ✅ 移除全局认证,在路由文件中选择性应用(支持游客模式) +app.use('/api/courses', courseRoutes); +app.use('/api/lessons', learningRoutes); +app.use('/api/payment', authenticate, paymentRoutes); +app.use('/api/upload', uploadRoutes); +app.use('/api/my-courses', myCoursesRoutes); +app.use('/api/notes', noteRoutes); +app.use('/api/notebooks', notebookRoutes); // ✅ Phase 2: 新增笔记本路由 +app.use('/api/ai/courses', aiCourseRoutes); // ✅ AI 课程生成路由 +app.use('/api/ai/topics', aiTopicsRoutes); // ✅ AI 热门主题路由 +app.use('/api/ai/thinking-flow', aiThinkingFlowRoutes); // ✅ AI 思考流路由 +app.use('/api/ai/prompts', promptRoutes); // ✅ Prompt 管理路由(V3.0) +app.use('/api/operational-banners', operationalBannerRoutes); // 发现页运营位(公开 GET) +app.use('/api/admin/operational-banners', operationalBannerAdminRoutes); // 运营位管理(后台) +app.use('/api/admin/generation-tasks', adminCallRecordsRouter); // 调用记录(后台) +app.use('/api/playground', playgroundRoutes); // 小红书封面模板 Playground +app.use('/api/analytics', analyticsRoutes); // ✅ V1.0 埋点体系 + +// Error handling middleware (must be last) +app.use(errorHandler); + +// 导出 app(用于测试) +export { app }; + +// 启动服务器(仅在非测试环境) +if (process.env.NODE_ENV !== 'test') { + app.listen(PORT, () => { + logger.info(`🚀 Server running on http://localhost:${PORT}`); + logger.info(`📝 Environment: ${process.env.NODE_ENV || 'development'}`); + }); +} + +export default app; \ No newline at end of file diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..1a910e3 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,103 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt, { SignOptions } from 'jsonwebtoken'; +import { CustomError } from './errorHandler'; + +export interface AuthRequest extends Request { + userId?: string; +} + +export const authenticate = ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new CustomError('未提供认证令牌', 401); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + if (!process.env.JWT_SECRET) { + throw new Error('JWT_SECRET未配置'); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET) as { + userId: string; + iat?: number; + exp?: number; + }; + + req.userId = decoded.userId; + next(); + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + next(new CustomError('无效的认证令牌', 401)); + } else if (error instanceof jwt.TokenExpiredError) { + next(new CustomError('认证令牌已过期', 401)); + } else { + next(error); + } + } +}; + +/** + * 可选认证中间件 + * - 如果有 Token:验证并设置 userId + * - 如果没有 Token:直接通过(userId 为 undefined) + * 用于支持游客模式访问公开接口 + */ +export const optionalAuthenticate = ( + req: AuthRequest, + res: Response, + next: NextFunction +) => { + const authHeader = req.headers.authorization; + + // 如果没有 Token,直接通过(游客模式) + if (!authHeader || !authHeader.startsWith('Bearer ')) { + req.userId = undefined; + return next(); + } + + // 如果有 Token,使用标准认证逻辑 + try { + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + if (!process.env.JWT_SECRET) { + throw new Error('JWT_SECRET未配置'); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET) as { + userId: string; + iat?: number; + exp?: number; + }; + + req.userId = decoded.userId; + next(); + } catch (error) { + // Token 无效时,也允许通过(游客模式),但 userId 为 undefined + // 这样可以让接口自己决定是否要求认证 + if (error instanceof jwt.JsonWebTokenError || error instanceof jwt.TokenExpiredError) { + req.userId = undefined; + return next(); + } else { + next(error); + } + } +}; + +export const generateToken = (userId: string): string => { + if (!process.env.JWT_SECRET) { + throw new Error('JWT_SECRET未配置'); + } + + const expiresIn = process.env.JWT_EXPIRES_IN || '7d'; + + // @ts-ignore - jsonwebtoken types issue + return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn }); +}; + diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..b5fcea9 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { logger } from '../utils/logger'; + +export interface AppError extends Error { + statusCode?: number; + isOperational?: boolean; +} + +export class CustomError extends Error implements AppError { + statusCode: number; + isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +export const errorHandler = ( + err: AppError, + req: Request, + res: Response, + next: NextFunction +) => { + const statusCode = err.statusCode || 500; + const message = err.message || 'Internal Server Error'; + + logger.error({ + error: err.message, + stack: err.stack, + path: req.path, + method: req.method, + }); + + res.status(statusCode).json({ + success: false, + error: { + message, + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), + }, + }); +}; + + + + + diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts new file mode 100644 index 0000000..f9ade79 --- /dev/null +++ b/backend/src/middleware/rateLimit.ts @@ -0,0 +1,71 @@ +import rateLimit from 'express-rate-limit'; +import { Request } from 'express'; + +/** + * 短信验证码发送频率限制 + * - 同一手机号:1 次/60 秒 + * - 同一 IP:5 次/60 秒 + */ +export const smsPhoneRateLimit = rateLimit({ + windowMs: 60 * 1000, // 1 分钟窗口 + max: 1, // 1 分钟内最多 1 次 + keyGenerator: (req: Request) => { + // 按手机号区分(如果提供了手机号) + return req.body?.phone || req.ip; + }, + standardHeaders: true, // 返回 `RateLimit-*` 头信息 + legacyHeaders: false, // 禁用 `X-RateLimit-*` 头信息 + handler: (req, res) => { + res.status(429).json({ + success: false, + error: { + message: '发送过于频繁,请 60 秒后再试', + }, + }); + }, +}); + +/** + * 短信验证码发送 IP 频率限制 + * - 同一 IP:5 次/60 秒(防止单 IP 批量攻击) + */ +export const smsIpRateLimit = rateLimit({ + windowMs: 60 * 1000, // 1 分钟窗口 + max: 5, // 1 分钟内同一 IP 最多 5 次 + keyGenerator: (req: Request) => req.ip || 'unknown', + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + res.status(429).json({ + success: false, + error: { + message: '请求过于频繁,请稍后再试', + }, + }); + }, +}); + +/** + * 登录尝试频率限制 + * - 同一手机号或 IP:5 次/30 分钟 + */ +export const loginRateLimit = rateLimit({ + windowMs: 30 * 60 * 1000, // 30 分钟窗口 + max: 5, // 30 分钟内最多 5 次 + keyGenerator: (req: Request) => { + // 按手机号区分(如果提供了手机号),否则按 IP + return req.body?.phone || req.ip || 'unknown'; + }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, // 登录成功不计入限制 + handler: (req, res) => { + res.status(429).json({ + success: false, + error: { + message: '登录尝试次数过多,请 30 分钟后重试', + }, + }); + }, +}); + diff --git a/backend/src/routes/adminCallRecordsRoutes.ts b/backend/src/routes/adminCallRecordsRoutes.ts new file mode 100644 index 0000000..7c723c5 --- /dev/null +++ b/backend/src/routes/adminCallRecordsRoutes.ts @@ -0,0 +1,15 @@ +/** + * 管理后台 - 调用记录(AI 课程生成任务) + */ + +import { Router } from 'express'; +import { listGenerationTasks, getGenerationTaskDetail, getAiCallLogDetail } from '../controllers/adminCallRecordsController'; + +export const adminCallRecordsRouter = Router(); + +adminCallRecordsRouter.get('/', listGenerationTasks); + +// AI 调用日志详情(单条)- 必须在 /:taskId 之前,否则会被匹配为 taskId +adminCallRecordsRouter.get('/ai-call-logs/:logId', getAiCallLogDetail); + +adminCallRecordsRouter.get('/:taskId', getGenerationTaskDetail); diff --git a/backend/src/routes/aiCourseRoutes.ts b/backend/src/routes/aiCourseRoutes.ts new file mode 100644 index 0000000..4894561 --- /dev/null +++ b/backend/src/routes/aiCourseRoutes.ts @@ -0,0 +1,33 @@ +/** + * AI 课程生成路由 + */ + +import { Router } from 'express'; +import { authenticate } from '../middleware/auth'; +import { + createCourse, + extractCourseContent, + getTaskStatus, +} from '../controllers/aiCourseController'; + +const router = Router(); + +/** + * 统一创建课程接口(支持 sourceType 和 persona) + * POST /api/ai/courses/create + */ +router.post('/create', authenticate, createCourse); + +/** + * 提取课程内容(用于续旧课) + * POST /api/ai/courses/:courseId/extract + */ +router.post('/:courseId/extract', authenticate, extractCourseContent); + +/** + * 获取任务状态 + * GET /api/ai/courses/:taskId/status + */ +router.get('/:taskId/status', authenticate, getTaskStatus); + +export default router; diff --git a/backend/src/routes/aiThinkingFlowRoutes.ts b/backend/src/routes/aiThinkingFlowRoutes.ts new file mode 100644 index 0000000..d9d3796 --- /dev/null +++ b/backend/src/routes/aiThinkingFlowRoutes.ts @@ -0,0 +1,17 @@ +/** + * AI 思考流路由 + */ + +import { Router } from 'express'; +import { authenticate } from '../middleware/auth'; +import { getThinkingFlow } from '../controllers/aiCourseController'; + +const router = Router(); + +/** + * 获取思考流文本 + * GET /api/ai/thinking-flow + */ +router.get('/', authenticate, getThinkingFlow); + +export default router; diff --git a/backend/src/routes/aiTopicsRoutes.ts b/backend/src/routes/aiTopicsRoutes.ts new file mode 100644 index 0000000..3b202ca --- /dev/null +++ b/backend/src/routes/aiTopicsRoutes.ts @@ -0,0 +1,20 @@ +/** + * AI 主题和思考流路由 + */ + +import { Router } from 'express'; +import { authenticate } from '../middleware/auth'; +import { + getPopularTopics, + getThinkingFlow, +} from '../controllers/aiCourseController'; + +const router = Router(); + +/** + * 获取热门主题 + * GET /api/ai/topics/popular + */ +router.get('/popular', authenticate, getPopularTopics); + +export default router; diff --git a/backend/src/routes/analyticsRoutes.ts b/backend/src/routes/analyticsRoutes.ts new file mode 100644 index 0000000..7525164 --- /dev/null +++ b/backend/src/routes/analyticsRoutes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { trackEvents } from '../controllers/analyticsController'; + +const router = Router(); + +/** + * @route POST /api/analytics/events + * @desc 批量上报客户端埋点事件 + * @access Public(不需要认证,支持匿名用户追踪) + */ +router.post('/events', trackEvents); + +export default router; diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts new file mode 100644 index 0000000..ebfecf3 --- /dev/null +++ b/backend/src/routes/authRoutes.ts @@ -0,0 +1,40 @@ +import { Router } from 'express'; +import { sendCode, login, appleLogin, getTestToken } from '../controllers/authController'; +import { smsPhoneRateLimit, smsIpRateLimit, loginRateLimit } from '../middleware/rateLimit'; + +const router = Router(); + +/** + * @route POST /api/auth/send-code + * @desc 发送验证码 + * @access Public + * @rateLimit 同一手机号 1 次/60 秒,同一 IP 5 次/60 秒 + */ +router.post('/send-code', smsIpRateLimit, smsPhoneRateLimit, sendCode); + +/** + * @route POST /api/auth/login + * @desc 手机号登录 + * @access Public + * @rateLimit 同一手机号或 IP 5 次/30 分钟 + */ +router.post('/login', loginRateLimit, login); + +/** + * @route POST /api/auth/apple-login + * @desc Apple 登录(模拟) + * @access Public + */ +router.post('/apple-login', appleLogin); + +/** + * @route GET /api/auth/test-token + * @desc 获取测试 Token(仅用于开发/管理工具) + * @access Public + */ +router.get('/test-token', getTestToken); + +export default router; + + + diff --git a/backend/src/routes/courseRoutes.ts b/backend/src/routes/courseRoutes.ts new file mode 100644 index 0000000..0ef7593 --- /dev/null +++ b/backend/src/routes/courseRoutes.ts @@ -0,0 +1,243 @@ +import { Router } from 'express'; +import { + createCourse, + getCourses, + getDiscoveryFeed, + getCourseMap, + getCourseStructure, + createChapter, + updateChapter, + deleteChapter, + createNode, + updateNode, + deleteNode, + reorderCourseStructure, + getNodeSlides, + createNodeSlide, + updateNodeSlide, + deleteNodeSlide, + reorderNodeSlides, + batchCreateChaptersAndNodes, + batchCreateNodeSlides, + updateCourse, + updateCourseCover, + generateCourseCover, + deleteCourse, + restoreCourse, + getDeletedCourses, + publishCourse +} from '../controllers/courseController'; +import { authenticate, optionalAuthenticate } from '../middleware/auth'; + +const router = Router(); + +/** + * @route POST /api/courses + * @desc 创建新课程 + * @access Private + */ +router.post('/', authenticate, createCourse); + +/** + * @route GET /api/courses + * @desc 获取课程列表(排除已删除的) + * @access Public (游客可访问,登录用户返回完整数据) + */ +router.get('/', optionalAuthenticate, getCourses); + +/** + * @route GET /api/courses/discovery-feed + * @desc 发现页专用:排除运营位课程,按加入课程数降序 + * @access Public + */ +router.get('/discovery-feed', optionalAuthenticate, getDiscoveryFeed); + +/** + * @route GET /api/courses/deleted + * @desc 获取已删除的课程列表 + * @access Private + */ +router.get('/deleted', authenticate, getDeletedCourses); + +/** + * @route GET /api/courses/:courseId/structure + * @desc 获取课程结构(章节和节点) + * @access Private + * 注意:必须在 /:courseId/map 之前,否则会被 /:courseId/map 匹配 + */ +router.get('/:courseId/structure', authenticate, getCourseStructure); + +/** + * @route GET /api/courses/:courseId/map + * @desc 获取课程地图 + * @access Public (游客可访问,登录用户返回完整数据) + */ +router.get('/:courseId/map', optionalAuthenticate, getCourseMap); + +/** + * @route GET /api/courses/:courseId/export + * @desc 导出小节课内容(所有节点) + * @access Private + * 注意:必须在 /:courseId/chapters/:chapterId/export 之前,否则会被更通用的路由匹配 + */ + +/** + * @route GET /api/courses/:courseId/chapters/:chapterId/export + * @desc 导出章节内容 + * @access Private + * 注意:必须在 /:chapterId 之前,否则会被更通用的路由匹配 + */ + +/** + * @route POST /api/courses/:courseId/chapters + * @desc 创建章节 + * @access Private + */ +router.post('/:courseId/chapters', authenticate, createChapter); + +/** + * @route PATCH /api/courses/:courseId/chapters/:chapterId + * @desc 更新章节 + * @access Private + */ +router.patch('/:courseId/chapters/:chapterId', authenticate, updateChapter); + +/** + * @route DELETE /api/courses/:courseId/chapters/:chapterId + * @desc 删除章节 + * @access Private + */ +router.delete('/:courseId/chapters/:chapterId', authenticate, deleteChapter); + +/** + * @route POST /api/courses/:courseId/nodes + * @desc 创建节点 + * @access Private + */ +router.post('/:courseId/nodes', authenticate, createNode); + +/** + * @route PATCH /api/courses/:courseId/nodes/:nodeId + * @desc 更新节点 + * @access Private + */ +router.patch('/:courseId/nodes/:nodeId', authenticate, updateNode); + +/** + * @route DELETE /api/courses/:courseId/nodes/:nodeId + * @desc 删除节点 + * @access Private + */ +router.delete('/:courseId/nodes/:nodeId', authenticate, deleteNode); + +/** + * @route PATCH /api/courses/:courseId/reorder + * @desc 批量更新顺序 + * @access Private + */ +router.patch('/:courseId/reorder', authenticate, reorderCourseStructure); + +/** + * @route POST /api/courses/:courseId/generate-cover + * @desc 生成课程封面图 + * @access Private + * 注意:必须在 /:courseId 之前,否则会被 /:courseId 匹配 + */ +router.post('/:courseId/generate-cover', authenticate, generateCourseCover); + +/** + * @route PATCH /api/courses/:courseId/cover + * @desc 更新课程封面图 + * @access Private + * 注意:必须在 /:courseId 之前,否则会被 /:courseId 匹配 + */ +router.patch('/:courseId/cover', authenticate, updateCourseCover); + +/** + * @route PATCH /api/courses/:courseId/publish + * @desc 发布/取消发布课程 + * @access Private + * 注意:必须在 /:courseId 之前,否则会被 /:courseId 匹配 + */ +router.patch('/:courseId/publish', authenticate, publishCourse); + +/** + * @route PATCH /api/courses/:courseId/restore + * @desc 恢复已删除的课程 + * @access Private + * 注意:必须在 /:courseId 之前,否则会被 /:courseId 匹配 + */ +router.patch('/:courseId/restore', authenticate, restoreCourse); + +// TODO: importMindMap 函数需要重新实现 +// router.post('/:courseId/import-mindmap', authenticate, importMindMap); + +/** + * @route DELETE /api/courses/:courseId + * @desc 删除课程(软删除) + * @access Private + * 注意:必须在 /:courseId 之前,否则会被 /:courseId 匹配 + */ +router.delete('/:courseId', authenticate, deleteCourse); + +/** + * @route PATCH /api/courses/:courseId + * @desc 更新课程基本信息(标题、副标题、描述) + * @access Private + */ +router.patch('/:courseId', authenticate, updateCourse); + +/** + * @route POST /api/courses/:courseId/chapters-nodes + * @desc 批量创建章节和节点 + * @access Private + */ +router.post('/:courseId/chapters-nodes', authenticate, batchCreateChaptersAndNodes); + +/** + * @route GET /api/courses/nodes/:nodeId/slides + * @desc 获取节点幻灯片 + * @access Public (游客可访问) + */ +router.get('/nodes/:nodeId/slides', optionalAuthenticate, getNodeSlides); + +/** + * @route PATCH /api/courses/nodes/:nodeId/slides/reorder + * @desc 批量更新幻灯片顺序 + * @access Private + * 注意:必须在 /nodes/:nodeId/slides/:slideId 之前 + */ +router.patch('/nodes/:nodeId/slides/reorder', authenticate, reorderNodeSlides); + +/** + * @route POST /api/courses/nodes/:nodeId/slides + * @desc 创建单个或批量创建幻灯片 + * @access Private + * 注意:如果body包含slides数组,则批量创建;否则单个创建 + */ +router.post('/nodes/:nodeId/slides', authenticate, (req, res, next) => { + // 检查body是否包含slides数组 + if (req.body.slides && Array.isArray(req.body.slides)) { + // 批量创建 + return batchCreateNodeSlides(req, res, next); + } else { + // 单个创建 + return createNodeSlide(req, res, next); + } +}); + +/** + * @route PATCH /api/courses/nodes/:nodeId/slides/:slideId + * @desc 更新幻灯片 + * @access Private + */ +router.patch('/nodes/:nodeId/slides/:slideId', authenticate, updateNodeSlide); + +/** + * @route DELETE /api/courses/nodes/:nodeId/slides/:slideId + * @desc 删除幻灯片 + * @access Private + */ +router.delete('/nodes/:nodeId/slides/:slideId', authenticate, deleteNodeSlide); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/learningRoutes.ts b/backend/src/routes/learningRoutes.ts new file mode 100644 index 0000000..68b5291 --- /dev/null +++ b/backend/src/routes/learningRoutes.ts @@ -0,0 +1,82 @@ +import { Router } from 'express'; +import { + getNodeContent, + startLesson, + updateProgress, + completeLesson, + getLessonSummary, + getLessonDetail, + updateVerticalLessonProgress, + getNodePlainText, +} from '../controllers/learningController'; +import { getNodeNotes } from '../controllers/noteController'; +import { authenticate, optionalAuthenticate } from '../middleware/auth'; + +const router = Router(); + +/** + * @route GET /api/lessons/nodes/:nodeId/detail + * @desc 获取竖屏课程详情(用于竖屏课程播放器) + * @access Public (游客可访问,登录用户返回完整数据) + */ +router.get('/nodes/:nodeId/detail', optionalAuthenticate, getLessonDetail); + +/** + * @route POST /api/lessons/nodes/:nodeId/progress + * @desc 上报竖屏课程学习进度(Float progress 0.0-1.0) + * @access Private + */ +router.post('/nodes/:nodeId/progress', authenticate, updateVerticalLessonProgress); + +/** + * @route GET /api/lessons/:nodeId/content + * @desc 获取节点内容(幻灯片数据) + * @access Public (游客可访问,登录用户返回完整数据) + */ +router.get('/:nodeId/content', optionalAuthenticate, getNodeContent); + +/** + * @route POST /api/lessons/:nodeId/start + * @desc 开始学习节点 + * @access Private + */ +router.post('/:nodeId/start', authenticate, startLesson); + +/** + * @route POST /api/lessons/:nodeId/progress + * @desc 上报学习进度 + * @access Private + */ +router.post('/:nodeId/progress', authenticate, updateProgress); + +/** + * @route POST /api/lessons/:nodeId/complete + * @desc 完成节点学习 + * @access Private + */ +router.post('/:nodeId/complete', authenticate, completeLesson); + +/** + * @route GET /api/lessons/:nodeId/summary + * @desc 获取完成总结数据 + * @access Private + */ +router.get('/:nodeId/summary', authenticate, getLessonSummary); + +/** + * @route GET /api/lessons/:nodeId/notes + * @desc 获取节点下的所有笔记(用于在课程内容中显示) + * @access Private + */ +router.get('/:nodeId/notes', authenticate, getNodeNotes); + +/** + * @route GET /api/lessons/:nodeId/plain-text + * @desc 获取节点纯文本内容(用于系统笔记管理) + * @access Private + */ +router.get('/:nodeId/plain-text', authenticate, getNodePlainText); + +export default router; + + diff --git a/backend/src/routes/myCoursesRoutes.ts b/backend/src/routes/myCoursesRoutes.ts new file mode 100644 index 0000000..3b08b10 --- /dev/null +++ b/backend/src/routes/myCoursesRoutes.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { + joinCourse, + removeCourse, + checkIsJoined, + getJoinedCourses, + updateLastOpened +} from '../controllers/myCoursesController'; +import { authenticate } from '../middleware/auth'; + +const router = Router(); + +/** + * @route GET /api/my-courses + * @desc 获取用户已加入的课程列表(按最近打开时间倒序) + * @access Private + */ +router.get('/', authenticate, getJoinedCourses); + +/** + * @route POST /api/my-courses/join + * @desc 加入课程 + * @access Private + */ +router.post('/join', authenticate, joinCourse); + +/** + * @route POST /api/my-courses/remove + * @desc 移除课程 + * @access Private + */ +router.post('/remove', authenticate, removeCourse); + +/** + * @route POST /api/my-courses/check + * @desc 检查课程是否已加入 + * @access Private + */ +router.post('/check', authenticate, checkIsJoined); + +/** + * @route POST /api/my-courses/update-last-opened + * @desc 更新课程最近打开时间 + * @access Private + */ +router.post('/update-last-opened', authenticate, updateLastOpened); + +export default router; diff --git a/backend/src/routes/noteRoutes.ts b/backend/src/routes/noteRoutes.ts new file mode 100644 index 0000000..ea65a54 --- /dev/null +++ b/backend/src/routes/noteRoutes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { + createNote, + getNotes, + getNote, + updateNote, + deleteNote, + getNodeNotes, + createSystemNote, +} from '../controllers/noteController'; +import { authenticate } from '../middleware/auth'; + +const router = Router(); + +// 所有笔记接口都需要认证 +router.post('/', authenticate, createNote); +router.post('/system', authenticate, createSystemNote); // ✅ 系统笔记创建接口 +router.get('/', authenticate, getNotes); +router.get('/:noteId', authenticate, getNote); +router.put('/:noteId', authenticate, updateNote); +router.delete('/:noteId', authenticate, deleteNote); + +export default router; diff --git a/backend/src/routes/notebookRoutes.ts b/backend/src/routes/notebookRoutes.ts new file mode 100644 index 0000000..1a21b9a --- /dev/null +++ b/backend/src/routes/notebookRoutes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { + createNotebook, + getNotebooks, + getNotebook, + updateNotebook, + deleteNotebook, +} from '../controllers/notebookController'; +import { authenticate } from '../middleware/auth'; + +const router = Router(); + +// 所有笔记本接口都需要认证 +router.post('/', authenticate, createNotebook); +router.get('/', authenticate, getNotebooks); +router.get('/:notebookId', authenticate, getNotebook); +router.put('/:notebookId', authenticate, updateNotebook); +router.delete('/:notebookId', authenticate, deleteNotebook); + +export default router; diff --git a/backend/src/routes/operationalBannerRoutes.ts b/backend/src/routes/operationalBannerRoutes.ts new file mode 100644 index 0000000..dca4c6a --- /dev/null +++ b/backend/src/routes/operationalBannerRoutes.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { + listPublic, + listAdmin, + create, + update, + remove, + updateOrder, + addCourse, + addCoursesBatch, + removeCourse, + updateCoursesOrder, +} from '../controllers/operationalBannerController'; + +const router = Router(); + +/** 公开:发现页拉取运营位(无需认证) */ +router.get('/', listPublic); + +export const adminRouter = Router({ mergeParams: true }); +adminRouter.get('/', listAdmin); +adminRouter.post('/', create); +adminRouter.put('/order', updateOrder); +adminRouter.patch('/:id', update); +adminRouter.delete('/:id', remove); +adminRouter.post('/:id/courses/batch', addCoursesBatch); +adminRouter.post('/:id/courses', addCourse); +adminRouter.delete('/:id/courses/:courseId', removeCourse); +adminRouter.put('/:id/courses/order', updateCoursesOrder); + +export default router; diff --git a/backend/src/routes/paymentRoutes.ts b/backend/src/routes/paymentRoutes.ts new file mode 100644 index 0000000..0f276f0 --- /dev/null +++ b/backend/src/routes/paymentRoutes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { verifyReceipt } from '../controllers/paymentController'; + +const router = Router(); + +/** + * @route POST /api/payment/verify-receipt + * @desc 验证 Apple 收据(MVP 阶段:模拟) + * @access Private + */ +router.post('/verify-receipt', verifyReceipt); + +export default router; + + + + diff --git a/backend/src/routes/playgroundRoutes.ts b/backend/src/routes/playgroundRoutes.ts new file mode 100644 index 0000000..4375d9a --- /dev/null +++ b/backend/src/routes/playgroundRoutes.ts @@ -0,0 +1,175 @@ +/** + * Playground 路由 + * - 小红书封面模板预览 + * - 结构分块测试工具 + */ + +import { Router, Request, Response } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs/promises'; +import { + generateXhsCover, + XHS_COVER_TEMPLATES, +} from '../services/xhsCoverTemplatesService'; +import { structureChunkingService } from '../services/structureChunkingService'; +import { documentParserService } from '../services/documentParserService'; +import { logger } from '../utils/logger'; + +const router = Router(); + +// 配置 multer 用于分块测试的文档上传 +const chunkingUploadStorage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, '/tmp'), + filename: (_req, file, cb) => cb(null, `chunking-test-${Date.now()}${path.extname(file.originalname)}`), +}); + +const chunkingUpload = multer({ + storage: chunkingUploadStorage, + limits: { fileSize: 100 * 1024 * 1024 }, // 100MB + fileFilter: (_req, file, cb) => { + const allowedTypes = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/epub+zip', + 'application/epub', + ]; + const allowedExts = ['.pdf', '.docx', '.epub']; + const ext = path.extname(file.originalname).toLowerCase(); + if (allowedTypes.includes(file.mimetype) || allowedExts.includes(ext)) { + cb(null, true); + } else { + cb(new Error('仅支持 PDF、DOCX、EPUB 格式')); + } + }, +}); + +// ==================== 结构分块测试 ==================== + +/** + * POST /api/playground/chunking/upload + * 上传文档进行分块测试(支持 PDF、DOCX、EPUB) + */ +router.post('/chunking/upload', chunkingUpload.single('file'), async (req: Request, res: Response) => { + const file = req.file; + if (!file) { + return res.status(400).json({ success: false, error: '请上传文件' }); + } + + try { + logger.info(`[ChunkingTest] 上传文件: ${file.originalname}, size=${file.size}`); + const startTime = Date.now(); + + // 1. 解析文档提取文本 + const text = await documentParserService.extractText(file.path, file.mimetype); + const parseTime = Date.now() - startTime; + + // 2. 结构分块(纯规则模式) + const chunkStartTime = Date.now(); + const result = await structureChunkingService.parseAsync(text); + const chunkTime = Date.now() - chunkStartTime; + + // 3. 清理临时文件 + await documentParserService.cleanupFile(file.path); + + // 为每个 chunk 添加内容预览 + const chunksWithPreview = result.chunks.map(chunk => ({ + ...chunk, + contentPreview: chunk.content.substring(0, 100) + (chunk.content.length > 100 ? '...' : ''), + contentLength: chunk.content.length, + })); + + res.json({ + success: true, + data: { + success: result.success, + pattern: result.pattern, + totalChunks: result.chunks.length, + totalCharacters: result.totalCharacters, + duration: `${parseTime + chunkTime}ms`, + parseTime: `${parseTime}ms`, + chunkTime: `${chunkTime}ms`, + filename: file.originalname, + fileSize: file.size, + failureReason: result.failureReason, + chunks: chunksWithPreview, + }, + }); + } catch (error: any) { + // 清理临时文件 + try { await fs.unlink(file.path); } catch {} + logger.error(`[ChunkingTest] 处理失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message || '处理失败' }); + } +}); + +/** + * POST /api/playground/chunking/test + * 测试文本按章分块 + */ +router.post('/chunking/test', async (req: Request, res: Response) => { + try { + const { text } = req.body; + + if (!text || typeof text !== 'string') { + return res.status(400).json({ + success: false, + error: '请提供 text 参数', + }); + } + + const startTime = Date.now(); + const result = await structureChunkingService.parseAsync(text); + const duration = Date.now() - startTime; + + // 为每个 chunk 添加内容预览 + const chunksWithPreview = result.chunks.map(chunk => ({ + ...chunk, + contentPreview: chunk.content.substring(0, 100) + (chunk.content.length > 100 ? '...' : ''), + contentLength: chunk.content.length, + })); + + res.json({ + success: true, + data: { + success: result.success, + pattern: result.pattern, + totalChunks: result.chunks.length, + totalCharacters: result.totalCharacters, + duration: `${duration}ms`, + failureReason: result.failureReason, + chunks: chunksWithPreview, + }, + }); + } catch (error: any) { + res.status(500).json({ + success: false, + error: error.message || '分块失败', + }); + } +}); + +router.get('/xhs-templates', (_req: Request, res: Response) => { + res.json({ success: true, data: XHS_COVER_TEMPLATES }); +}); + +router.get('/xhs-cover/:templateId', (req: Request, res: Response) => { + const { templateId } = req.params; + const rawLines = typeof req.query.text === 'string' ? req.query.text : null; + const lines = rawLines ? rawLines.split('|').map((s) => s.trim()).filter(Boolean) : undefined; + + try { + const buffer = generateXhsCover(templateId, { lines }); + res.set('Content-Type', 'image/png'); + res.set('Cache-Control', 'public, max-age=300'); + res.send(buffer); + } catch (e: any) { + res.status(400).json({ + success: false, + error: e?.message || '生成失败', + validTemplates: XHS_COVER_TEMPLATES.map((t) => t.id), + }); + } +}); + +export default router; diff --git a/backend/src/routes/promptRoutes.ts b/backend/src/routes/promptRoutes.ts new file mode 100644 index 0000000..38c5be0 --- /dev/null +++ b/backend/src/routes/promptRoutes.ts @@ -0,0 +1,68 @@ +/** + * Prompt 管理路由(V3.0) + */ + +import { Router } from 'express'; +import { + getAllPrompts, + getPrompt, + setPrompt, + resetPrompt, + getPromptLogs, + getPromptLogDetail, + setModel, + deleteModel, +} from '../controllers/promptController'; +// 注意:后台管理系统不需要认证,直接访问 + +const router = Router(); + +/** + * 获取所有Prompt配置 + * GET /api/ai/prompts/v3 + */ +router.get('/v3', getAllPrompts); + +/** + * 获取单个Prompt配置 + * GET /api/ai/prompts/v3/:type + */ +router.get('/v3/:type', getPrompt); + +/** + * 设置Prompt配置 + * PUT /api/ai/prompts/v3/:type + */ +router.put('/v3/:type', setPrompt); + +/** + * 重置Prompt配置为默认值 + * POST /api/ai/prompts/v3/:type/reset + */ +router.post('/v3/:type/reset', resetPrompt); + +/** + * 设置模型配置 + * PUT /api/ai/prompts/v3/:type/model + */ +router.put('/v3/:type/model', setModel); + +/** + * 删除模型配置(恢复默认) + * DELETE /api/ai/prompts/v3/:type/model + */ +router.delete('/v3/:type/model', deleteModel); + +/** + * 获取调用日志列表 + * GET /api/ai/prompts/v3/logs + */ +router.get('/v3/logs', getPromptLogs); + +/** + * 获取调用日志详情 + * GET /api/ai/prompts/v3/logs/:id + */ +router.get('/v3/logs/:id', getPromptLogDetail); + +export default router; diff --git a/backend/src/routes/uploadRoutes.ts b/backend/src/routes/uploadRoutes.ts new file mode 100644 index 0000000..033cb7a --- /dev/null +++ b/backend/src/routes/uploadRoutes.ts @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import { uploadImage, uploadDocument } from '../controllers/uploadController'; +import { authenticate } from '../middleware/auth'; + +const router = Router(); + +/** + * @route POST /api/upload/image + * @desc 上传图片 + * @access Private + */ +router.post('/image', authenticate, uploadImage); + +/** + * @route POST /api/upload/document + * @desc 上传文档(PDF/Word)并提取文本 + * @access Private + * @body FormData with 'document' field + * @returns { success: true, data: { text: string, originalLength: number, filename: string } } + */ +router.post('/document', authenticate, uploadDocument); + +export default router; + + + diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts new file mode 100644 index 0000000..833fe4b --- /dev/null +++ b/backend/src/routes/userRoutes.ts @@ -0,0 +1,46 @@ +import { Router } from 'express'; +import { getProfile, updateProfile, getSettings, updateSettings, deleteAccount } from '../controllers/userController'; +import { authenticate } from '../middleware/auth'; + +const router = Router(); + +/** + * @route GET /api/user/profile + * @desc 获取用户信息 + * @access Private + */ +router.get('/profile', authenticate, getProfile); + +/** + * @route PUT /api/user/profile + * @desc 更新用户昵称 + * @access Private + */ +router.put('/profile', authenticate, updateProfile); + +/** + * @route GET /api/user/settings + * @desc 获取用户设置 + * @access Private + */ +router.get('/settings', authenticate, getSettings); + +/** + * @route PUT /api/user/settings + * @desc 更新用户设置 + * @access Private + */ +router.put('/settings', authenticate, updateSettings); + +/** + * @route DELETE /api/user/account + * @desc 删除用户账号(Apple App Store 强制要求) + * @access Private + */ +router.delete('/account', authenticate, deleteAccount); + +export default router; + + + + diff --git a/backend/src/services/appleAuthService.ts b/backend/src/services/appleAuthService.ts new file mode 100644 index 0000000..ee15e35 --- /dev/null +++ b/backend/src/services/appleAuthService.ts @@ -0,0 +1,152 @@ +import jwt from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; +import { logger } from '../utils/logger'; +import { CustomError } from '../middleware/errorHandler'; + +/** + * Apple 公钥客户端 + * 用于获取 Apple 的公钥来验证 identityToken + */ +const appleJwksClient = jwksClient({ + jwksUri: 'https://appleid.apple.com/auth/keys', + cache: true, + cacheMaxAge: 86400000, // 24小时缓存 +}); + +/** + * Apple Sign In 配置 + */ +interface AppleAuthConfig { + clientId: string; // Bundle ID: com.mustmaster.WildGrowth +} + +let appleConfig: AppleAuthConfig | null = null; + +/** + * 初始化 Apple 认证服务 + */ +export function initAppleAuthService(): void { + const clientId = process.env.APPLE_CLIENT_ID || 'com.mustmaster.WildGrowth'; + + appleConfig = { + clientId, + }; + + logger.info('✅ Apple 认证服务初始化成功'); + logger.info(` Client ID: ${clientId}`); +} + +/** + * 验证 Apple identityToken + * @param identityToken Apple 返回的 JWT token + * @returns 验证后的 token payload + */ +export async function verifyIdentityToken(identityToken: string): Promise { + if (!appleConfig) { + initAppleAuthService(); + } + + try { + // 1. 解码 token(不验证签名,先获取 header) + const decoded = jwt.decode(identityToken, { complete: true }) as { + header: { kid: string; alg: string }; + payload: any; + } | null; + + if (!decoded || !decoded.header || !decoded.payload) { + throw new CustomError('无效的 identityToken 格式', 400); + } + + // 2. 获取 Apple 公钥 + const key = await appleJwksClient.getSigningKey(decoded.header.kid); + const publicKey = key.getPublicKey(); + + // 3. 验证 token(签名、过期时间、发行者、受众) + const verified = jwt.verify(identityToken, publicKey, { + algorithms: ['RS256'], + issuer: 'https://appleid.apple.com', + audience: appleConfig!.clientId, + }) as any; + + logger.debug('✅ Apple identityToken 验证成功', { + sub: verified.sub, + email: verified.email ? '已提供' : '未提供', + }); + + return verified; + } catch (error: any) { + logger.error('❌ Apple identityToken 验证失败:', error.message); + + if (error.name === 'TokenExpiredError') { + throw new CustomError('identityToken 已过期', 401); + } else if (error.name === 'JsonWebTokenError') { + throw new CustomError('无效的 identityToken', 400); + } else if (error.name === 'NotBeforeError') { + throw new CustomError('identityToken 尚未生效', 400); + } + + throw new CustomError(`Apple 认证失败: ${error.message}`, 401); + } +} + +/** + * 验证 authorizationCode(可选,用于获取 refresh token) + * 注意:这需要 Apple Client Secret,通常用于 Web 登录 + * 对于 iOS App,主要验证 identityToken 即可 + * + * @param authorizationCode Apple 返回的授权码 + * @returns refresh token(如果成功) + */ +export async function verifyAuthorizationCode( + authorizationCode: string +): Promise { + // 注意:iOS App 通常不需要验证 authorizationCode + // 只有在需要长期验证用户身份时才需要 + // 这里暂时不实现,因为需要生成 Apple Client Secret + + logger.warn('⚠️ authorizationCode 验证未实现(iOS App 通常不需要)'); + return null; +} + +/** + * 从 identityToken 中提取用户信息 + * @param identityTokenPayload 验证后的 token payload + * @param appleUser iOS App 传递的用户信息(首次登录时提供) + * @returns 用户信息 + */ +export function extractUserInfo( + identityTokenPayload: any, + appleUser?: { + email?: string; + fullName?: { + givenName?: string; + familyName?: string; + }; + } +): { + appleId: string; + email?: string; + nickname?: string; +} { + // Apple ID(唯一标识) + const appleId = identityTokenPayload.sub; + + // 邮箱(首次登录时在 token 中,后续登录可能没有) + const email = identityTokenPayload.email || appleUser?.email; + + // 昵称(首次登录时在 appleUser 中提供) + let nickname: string | undefined; + if (appleUser?.fullName) { + const { givenName, familyName } = appleUser.fullName; + nickname = `${givenName || ''} ${familyName || ''}`.trim() || undefined; + } + + return { + appleId, + email, + nickname, + }; +} + + + diff --git a/backend/src/services/contentService.ts b/backend/src/services/contentService.ts new file mode 100644 index 0000000..eefe4c0 --- /dev/null +++ b/backend/src/services/contentService.ts @@ -0,0 +1,232 @@ +/** + * 内容生成服务 + */ + +import { PrismaClient } from '@prisma/client'; +import { Outline } from '../types/ai'; +import { taskService } from './taskService'; +import { logger } from '../utils/logger'; +import prisma from '../utils/prisma'; + +export class ContentService { + constructor(private db: PrismaClient = prisma) {} + + /** + * 基于大纲生成所有节点内容 + * 书籍解析模式:suggestedContent 已完整,直接保存 + */ + async generateAllContent( + taskId: string, + courseId: string, + sourceText: string, + outline: Outline + ): Promise { + try { + logger.info(`[ContentService] 开始生成内容: taskId=${taskId}`); + + await taskService.updateStatus(taskId, 'content_generating', 60, 'content'); + + // 1. 创建章节和节点 + const chapterIds: string[] = []; + const nodeTasks: Array<{ nodeId: string; chapterId: string; title: string; suggestedContent?: string }> = []; + + // ✅ 全局节点计数器:确保整个课程内的 orderIndex 唯一 + let globalNodeIndex = 0; + + for (let chapterIndex = 0; chapterIndex < outline.chapters.length; chapterIndex++) { + const chapterData = outline.chapters[chapterIndex]; + + // 创建章节 + const chapter = await this.db.courseChapter.create({ + data: { + courseId, + title: chapterData.title, + orderIndex: chapterIndex, + }, + }); + chapterIds.push(chapter.id); + + // 准备节点生成任务 + for (let nodeIndex = 0; nodeIndex < chapterData.nodes.length; nodeIndex++) { + const nodeData = chapterData.nodes[nodeIndex]; + + // 先创建节点(内容为空) + // ✅ 使用全局计数器,确保整个课程内的 orderIndex 唯一 + const node = await this.db.courseNode.create({ + data: { + courseId, + chapterId: chapter.id, + title: nodeData.title, + orderIndex: globalNodeIndex, // ✅ 使用全局计数器 + }, + }); + + // ✅ 递增全局计数器 + globalNodeIndex++; + + nodeTasks.push({ + nodeId: node.id, + chapterId: chapter.id, + title: nodeData.title, + suggestedContent: nodeData.suggestedContent, + }); + } + } + + // 2. 更新课程描述与节点数(标题由并行流程「课程标题生成」写入,此处不覆盖) + await this.db.course.update({ + where: { id: courseId }, + data: { + description: outline.summary || 'AI 生成的课程', + totalNodes: nodeTasks.length, + }, + }); + + const courseRow = await this.db.course.findUnique({ where: { id: courseId } }); + const titleForCover = courseRow?.title || outline.summary || '新课程'; + this.generateCoverImageAsync(courseId, titleForCover).catch((error) => { + logger.error(`[ContentService] 异步生成封面图失败: courseId=${courseId}`, error); + }); + + // 3. 生成节点内容 + const totalNodes = nodeTasks.length; + let completedNodes = 0; + + for (const nodeTask of nodeTasks) { + try { + // 只有完全没有(null/undefined)才不写,该节点无 slide → 接口无 blocks → 前端显示「内容准备中」 + if (nodeTask.suggestedContent != null && nodeTask.suggestedContent !== undefined) { + await this.saveNodeContentDirect( + nodeTask.nodeId, + nodeTask.title, + nodeTask.suggestedContent + ); + } else { + logger.warn(`[ContentService] 节点 ${nodeTask.nodeId} 的 suggestedContent 为 null/undefined,跳过写入`); + } + + completedNodes++; + const progress = 60 + Math.floor((completedNodes / totalNodes) * 35); // 60-95% + await taskService.updateStatus( + taskId, + 'content_generating', + progress, + `node_${nodeTask.nodeId}` + ); + } catch (error: any) { + logger.error( + `[ContentService] 生成节点内容失败: nodeId=${nodeTask.nodeId}, error=${error.message}` + ); + // 继续生成其他节点,不中断整个流程 + } + } + + // 4. 完成 + await taskService.updateStatus(taskId, 'completed', 100, 'completed'); + + // ✅ 5. 仅当非「保存为草稿」且课程有创建者时:自动发布并加入课程(App 流程);用 Course.createdAsDraft 判断,不额外查 Task(App 路径零额外查询) + type CourseSelect = { createdBy: true; createdAsDraft: true }; + const course = (await this.db.course.findUnique({ + where: { id: courseId }, + select: { createdBy: true, createdAsDraft: true } as CourseSelect, + })) as { createdBy: string | null; createdAsDraft?: boolean } | null; + + const skipAutoPublish = course?.createdAsDraft === true; + logger.info(`[ContentService] 课程查询: courseId=${courseId}, createdBy=${course?.createdBy || 'null'}, createdAsDraft=${skipAutoPublish}`); + + if (!skipAutoPublish && course?.createdBy) { + logger.info(`[ContentService] 检测到用户创建的课程,开始自动发布: courseId=${courseId}, userId=${course.createdBy}`); + + await this.db.course.update({ + where: { id: courseId }, + data: { status: 'published' }, + }); + logger.info(`[ContentService] ✅ 用户创建的课程已自动发布: courseId=${courseId}`); + + try { + const existingUserCourse = await this.db.userCourse.findUnique({ + where: { + userId_courseId: { userId: course.createdBy, courseId }, + }, + }); + if (!existingUserCourse) { + await this.db.userCourse.create({ + data: { userId: course.createdBy, courseId }, + }); + logger.info(`[ContentService] ✅ 已自动创建 UserCourse: userId=${course.createdBy}, courseId=${courseId}`); + } else { + logger.info(`[ContentService] ⚠️ UserCourse 已存在,跳过: userId=${course.createdBy}, courseId=${courseId}`); + } + } catch (error: any) { + logger.error(`[ContentService] ❌ 创建 UserCourse 失败: userId=${course.createdBy}, courseId=${courseId}`, error); + } + } else if (skipAutoPublish) { + logger.info(`[ContentService] 本次为保存为草稿,跳过自动发布和加入: courseId=${courseId}`); + } else { + logger.info(`[ContentService] 课程无创建者(createdBy=null),跳过自动发布和加入: courseId=${courseId}`); + } + + logger.info(`[ContentService] 内容生成完成: taskId=${taskId}, 共生成 ${totalNodes} 个节点`); + } catch (error: any) { + logger.error(`[ContentService] 生成内容失败: ${error.message}`, error); + await taskService.updateStatus( + taskId, + 'failed', + 0, + 'content', + `生成内容失败: ${error.message}` + ); + throw error; + } + } + + /** + * 直接保存节点内容(书籍解析等已生成内容的场景) + * 按「空行」拆成自然段:段间用 \n\n,段内可含 \n,实现段内紧(行距)、段间松(段距)。 + */ + private async saveNodeContentDirect( + nodeId: string, + _nodeTitle: string, + content: string + ): Promise { + const paragraphs = content + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter((p) => p.length > 0); + const slideContent = { + paragraphs: paragraphs.length > 0 ? paragraphs : [content], + }; + await this.db.nodeSlide.create({ + data: { + nodeId, + slideType: 'text', + orderIndex: 0, + content: slideContent as any, + }, + }); + logger.info(`[ContentService] 节点内容已直接存储: nodeId=${nodeId}`); + } + + /** + * 异步生成封面图(不阻塞主流程) + */ + private async generateCoverImageAsync(courseId: string, courseTitle: string): Promise { + try { + const { generateCourseCover } = await import('./coverImageService'); + const coverImagePath = await generateCourseCover(courseId, courseTitle, 'full'); + if (coverImagePath) { + await this.db.course.update({ + where: { id: courseId }, + data: { coverImage: coverImagePath }, + }); + logger.info(`[ContentService] 封面图生成成功: courseId=${courseId}, path=${coverImagePath}`); + } + } catch (error: any) { + logger.error(`[ContentService] 生成封面图失败: courseId=${courseId}, error=${error.message}`); + // 不抛出错误,避免影响主流程 + } + } + +} + +export const contentService = new ContentService(); diff --git a/backend/src/services/courseGenerationService.ts b/backend/src/services/courseGenerationService.ts new file mode 100644 index 0000000..8781f8a --- /dev/null +++ b/backend/src/services/courseGenerationService.ts @@ -0,0 +1,534 @@ +/** + * 课程生成服务 + * 支持三种生成模式:文本解析、直接生成、续旧课 + * 统一使用 chaptered_content 格式 + * + * ✅ 新增:对 document 类型支持结构分块(按章节边界切分) + */ + +import { Outline, ChapteredResponse } from '../types/ai'; +import { taskService } from './taskService'; +import { contentService } from './contentService'; +import { ModelProviderFactory, DOUBAO_MODEL, DOUBAO_MODEL_LITE } from './modelProvider'; +import { logger } from '../utils/logger'; +import { getPromptTemplate, getModelConfig, PromptType, ModelConfigurablePromptType } from './promptConfigService'; +import { generateAndUpdateCourseTitle } from './titleGenerationService'; +import { structureChunkingService, Chunk } from './structureChunkingService'; +import prisma from '../utils/prisma'; + +/** + * 将 Markdown 格式转换为 HTML 格式,以便前端正确渲染 + * - **粗体** → 粗体 (前端支持 标签) + * - ==高亮== → 高亮 (前端支持此格式) + * - 开头 == 前、结尾 == 后允许句末标点 。!?,标点不纳入高亮 + */ +function convertMarkdownToHTML(markdown: string): string { + let html = markdown; + + // 转换 ==高亮== 为 高亮;允许前后句末标点,标点不进入高亮 + // ([。!?])? == 开头前可选标点;(.+?) 高亮内容(非贪婪,支持内容内含 =);== ([。!?])? 结尾后可选标点 + html = html.replace(/([。!?])?==(.+?)==([。!?])?/g, (_m, before, content, after) => + (before ?? '') + "" + content + "" + (after ?? '')); + + // 转换 **粗体** 为 粗体 + html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); + + return html; +} + +/** + * 将chaptered_content格式转换为统一的Outline结构 + * ✅ 所有模式统一格式,代码更简洁 + */ +function convertToOutline(response: ChapteredResponse): Outline { + if (!response.chaptered_content || !Array.isArray(response.chaptered_content)) { + throw new Error('课程内容格式错误:缺少 chaptered_content'); + } + + const chapters = response.chaptered_content.map((chapter) => ({ + title: chapter.parent_title ?? '', + nodes: chapter.sections.map((section) => ({ + title: section.section_title, + suggestedContent: convertMarkdownToHTML(section.section_interpretation), + })), + })); + + const totalNodes = chapters.reduce((sum, ch) => sum + ch.nodes.length, 0); + logger.info(`[CourseGenerationService] 转换完成: ${chapters.length} 章,${totalNodes} 个节点`); + + return { + summary: `课程(${chapters.length}章)`, + chapters, + }; +} + +/** 单次模型输入上限;≤ 此值走单次生成,> 此值代码切块后并行生成 */ +const CHUNK_SIZE = 20000; + +/** + * 代码切块:在自然边界(段、句)处切分,每块不超过 maxChunk 字 + */ +function chunkingByCode(text: string, maxChunk: number): string[] { + const chunks: string[] = []; + let start = 0; + while (start < text.length) { + let end = Math.min(start + maxChunk, text.length); + if (end < text.length) { + const searchStart = Math.max(start, end - 1200); + const segment = text.slice(searchStart, end + 600); + const separators = ['\n\n', '。\n', '。', '!', '?', '\n']; + let found = -1; + for (const sep of separators) { + const idx = segment.lastIndexOf(sep); + if (idx !== -1) { + found = searchStart + idx + sep.length; + break; + } + } + if (found > start) end = found; + } + chunks.push(text.slice(start, end)); + start = end; + } + return chunks; +} + +/** + * 合并多段生成结果为统一的 ChapteredResponse + */ +function mergeResults(results: ChapteredResponse[]): ChapteredResponse { + const chapters: any[] = []; + for (const r of results) { + chapters.push(...r.chaptered_content); + } + return { chaptered_content: chapters }; +} + +// 支持从配置读取模型的 persona 列表 +const CONFIGURABLE_PERSONAS: Record = { + // 直接生成的三个豆包 Lite 选项 + 'direct_test_lite': 'direct_generation_lite', + 'direct_test_lite_outline': 'direct_generation_lite_outline', + 'direct_test_lite_summary': 'direct_generation_lite_summary', + // 文本解析的三个独立 Prompt 选项 + 'text_parse_xiaohongshu': 'text_parse_xiaohongshu', + 'text_parse_xiaolin': 'text_parse_xiaolin', + 'text_parse_douyin': 'text_parse_douyin', + // 续旧课的三个独立 Prompt 选项 + 'continue_course_xiaohongshu': 'continue_course_xiaohongshu', + 'continue_course_xiaolin': 'continue_course_xiaolin', + 'continue_course_douyin': 'continue_course_douyin', +}; + +export class CourseGenerationService { + + /** + * 并行生成 + 失败分块重试一次 + * 第一轮:并行生成所有分块 + * 第二轮:对失败的分块重试一次,仍失败则整体失败 + * 结果按 chunkIndex 顺序返回 + */ + private async parallelWithRetry( + generators: Array<() => Promise>, + taskId: string + ): Promise { + // 第一轮:并行生成 + const firstRound = await Promise.allSettled(generators.map(fn => fn())); + + const results: Map = new Map(); + const failedIndices: number[] = []; + + for (let i = 0; i < firstRound.length; i++) { + const r = firstRound[i]; + if (r.status === 'fulfilled') { + results.set(i, r.value); + } else { + failedIndices.push(i); + logger.warn(`[CourseGenerationService] 分块 ${i} 第一轮生成失败: ${(r as PromiseRejectedResult).reason?.message || '未知错误'}`); + } + } + + if (failedIndices.length === 0) { + // 全部成功 + return Array.from({ length: generators.length }, (_, i) => results.get(i)!); + } + + logger.info(`[CourseGenerationService] ${failedIndices.length}/${generators.length} 个分块失败,开始重试(taskId=${taskId})`); + + // 第二轮:并行重试失败的分块 + const retryResults = await Promise.allSettled(failedIndices.map(i => generators[i]())); + + for (let j = 0; j < failedIndices.length; j++) { + const idx = failedIndices[j]; + const r = retryResults[j]; + if (r.status === 'fulfilled') { + results.set(idx, r.value); + logger.info(`[CourseGenerationService] 分块 ${idx} 重试成功`); + } else { + throw new Error(`分块 ${idx} 生成失败(已重试1次): ${(r as PromiseRejectedResult).reason?.message || '未知错误'}`); + } + } + + logger.info(`[CourseGenerationService] 重试完成,全部 ${generators.length} 个分块已成功`); + return Array.from({ length: generators.length }, (_, i) => results.get(i)!); + } + + /** + * 异步生成课程 + * 根据sourceType选择生成模式,根据persona替换Prompt变量 + * 支持:1) 新 persona 从配置读取模型 2) 长文本并行生成 + */ + async processCourseGeneration(taskId: string): Promise { + try { + logger.info(`[CourseGenerationService] 开始课程生成: taskId=${taskId}`); + + const task = await taskService.getTask(taskId); + const { sourceType, persona, sourceText, courseId } = task; + + // 确定本次使用的模型 + const modelId = await this.determineModel(sourceType, persona); + await taskService.saveModelId(taskId, modelId); + logger.info(`[CourseGenerationService] 使用模型: ${modelId}`); + + await taskService.updateStatus(taskId, 'content_generating', 10); + + // 并行:课程标题生成(前 2000 字,不阻塞主流程;与主流程使用同一模型) + generateAndUpdateCourseTitle(courseId, sourceText, modelId).catch((err) => { + logger.warn(`[CourseGenerationService] 标题生成失败(不影响主流程): taskId=${taskId}`, err); + }); + + // 1. 获取 Prompt(续旧课会注入 {{accumulated_summary}}) + const prompt = await this.getPromptForTask(sourceType, persona, courseId); + logger.info(`[CourseGenerationService] 生成模式: sourceType=${sourceType}, persona=${persona || 'null'}`); + + // 1.1 存真实提示词供调用记录查看(fire-and-forget,零延迟) + setImmediate(() => { + taskService.savePromptSent(taskId, prompt).catch(() => {}); + }); + + // 2. 调用AI生成 + const adapter = ModelProviderFactory.create('doubao'); + let result: ChapteredResponse; + + // ✅ 新增:对 document 类型尝试结构分块 + if (sourceType === 'document') { + result = await this.generateWithStructureChunking( + adapter, sourceText, prompt, taskId, courseId, modelId + ); + } else if (sourceText.length <= CHUNK_SIZE) { + // ≤ CHUNK_SIZE:单次生成,失败重试一次 + try { + result = await this.generateOnce(adapter, sourceText, prompt, taskId, courseId, undefined, modelId); + } catch (firstError: any) { + logger.warn(`[CourseGenerationService] 单次生成失败,开始重试: ${firstError.message}`); + result = await this.generateOnce(adapter, sourceText, prompt, taskId, courseId, undefined, modelId); + logger.info(`[CourseGenerationService] 单次生成重试成功`); + } + } else { + // > CHUNK_SIZE:代码切块,并行生成 + 失败重试一次,按顺序合并 + const chunks = chunkingByCode(sourceText, CHUNK_SIZE); + logger.info(`[CourseGenerationService] 文档已切为 ${chunks.length} 段,开始并行生成`); + + await taskService.updateStatus(taskId, 'content_generating', 15, `正在并行生成 ${chunks.length} 段`); + + const generators = chunks.map((chunk, i) => + () => this.generateOnce(adapter, chunk, prompt, taskId, courseId, i, modelId) + ); + + const results = await this.parallelWithRetry(generators, taskId); + + result = mergeResults(results); + logger.info(`[CourseGenerationService] 并行生成完成,各段已合并: 共 ${result.chaptered_content.length} 个章节/单元`); + } + + // 3. 转换为统一的Outline结构 + const outline = convertToOutline(result); + logger.info(`[CourseGenerationService] 转换完成: ${outline.chapters.length} 章`); + + // 3.1 仅后台查看用:异步写入大纲,不 await、不阻塞、失败不影响主流程 + setImmediate(() => { + taskService.saveOutline(taskId, outline).catch(() => {}); + }); + + // 4. 保存到数据库 + await contentService.generateAllContent(taskId, courseId, sourceText, outline); + + logger.info(`[CourseGenerationService] 课程生成完成: taskId=${taskId}`); + + // 5. 异步生成知识点摘要(静默,不影响主流程) + this.generateAndSaveSummary(courseId, outline).catch((err) => { + logger.warn(`[CourseGenerationService] 知识点摘要生成失败(不影响主流程): courseId=${courseId}`, err); + }); + } catch (error: any) { + logger.error(`[CourseGenerationService] 课程生成失败: taskId=${taskId}, error=${error.message}`, error); + await taskService.updateStatus(taskId, 'failed', 0, undefined, `课程生成失败: ${error.message}`); + throw error; + } + } + + /** + * 确定使用的模型 + * 优先从配置读取,无配置则使用默认值 + */ + private async determineModel(sourceType: string | null | undefined, persona: string | null | undefined): Promise { + // 检查是否是可配置模型的 persona + if (persona && CONFIGURABLE_PERSONAS[persona]) { + const promptType = CONFIGURABLE_PERSONAS[persona]; + const configuredModel = await getModelConfig(promptType); + if (configuredModel) { + logger.info(`[CourseGenerationService] 使用配置的模型: persona=${persona}, model=${configuredModel}`); + return configuredModel; + } + // 无配置时的默认值:直接生成的 lite 选项用 Lite,文本解析的独立 Prompt 用 Flash + if (persona.startsWith('direct_test_lite')) { + return DOUBAO_MODEL_LITE; + } + return DOUBAO_MODEL; // 文本解析独立 Prompt 默认用 Flash + } + + // 其他情况:使用 Flash + return DOUBAO_MODEL; + } + + /** + * 获取任务对应的 Prompt + * courseId: 续旧课时用于读取 parentCourse 的 accumulatedSummary + */ + private async getPromptForTask(sourceType: string | null | undefined, persona: string | null | undefined, courseId?: string): Promise { + // 直接生成的三个豆包 Lite 选项:使用对应 Prompt,不做 persona 替换 + if (sourceType === 'direct' && persona === 'direct_test_lite') { + return await getPromptTemplate('direct_generation_lite'); + } + if (sourceType === 'direct' && persona === 'direct_test_lite_outline') { + return await getPromptTemplate('direct_generation_lite_outline'); + } + if (sourceType === 'direct' && persona === 'direct_test_lite_summary') { + return await getPromptTemplate('direct_generation_lite_summary'); + } + + // 文本解析的三个独立 Prompt 选项:使用对应 Prompt,不做 persona 替换 + if (sourceType === 'document' && persona === 'text_parse_xiaohongshu') { + return await getPromptTemplate('text_parse_xiaohongshu'); + } + if (sourceType === 'document' && persona === 'text_parse_xiaolin') { + return await getPromptTemplate('text_parse_xiaolin'); + } + if (sourceType === 'document' && persona === 'text_parse_douyin') { + return await getPromptTemplate('text_parse_douyin'); + } + + // 续旧课的三个独立 Prompt 选项:使用对应 Prompt,注入 {{accumulated_summary}} + if (sourceType === 'continue' && persona?.startsWith('continue_course_')) { + const promptType = persona as PromptType; + let promptTemplate = await getPromptTemplate(promptType); + + // 读取父课程的 accumulatedSummary 注入 Prompt + let accumulatedSummary = '(无已有知识点摘要,这是第一次续旧课)'; + if (courseId) { + try { + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { parentCourseId: true }, + }); + if (course?.parentCourseId) { + const parentCourse = await prisma.course.findUnique({ + where: { id: course.parentCourseId }, + select: { accumulatedSummary: true }, + }); + if (parentCourse?.accumulatedSummary) { + accumulatedSummary = parentCourse.accumulatedSummary; + } + } + } catch (err: any) { + logger.warn(`[CourseGenerationService] 读取父课程摘要失败: ${err.message}`); + } + } + promptTemplate = promptTemplate.replace(/\{\{accumulated_summary\}\}/g, accumulatedSummary); + return promptTemplate; + } + + // 其他情况:使用 fallback 逻辑(根据 sourceType 选择 Prompt 类型,不再做 persona 替换) + const promptType = this.getPromptType(sourceType); + return await getPromptTemplate(promptType); + } + + /** + * 根据sourceType获取Prompt类型 + */ + private getPromptType(sourceType: string | null | undefined): PromptType { + switch (sourceType) { + case 'document': + return 'text_parse'; + case 'direct': + return 'direct_generation'; + case 'continue': + return 'continue_course'; + default: + return 'text_parse'; // 默认文本解析 + } + } + + /** + * ✅ 新增:使用结构分块进行生成 + * 对 document 类型:先尝试识别章节结构,按结构分块后并行生成 + * 如果结构识别失败,降级到按字数分块 + */ + private async generateWithStructureChunking( + adapter: any, + sourceText: string, + prompt: string, + taskId: string, + courseId: string, + modelId: string + ): Promise { + // 1. 尝试按章分块(LLM 增强版) + const chunkingResult = await structureChunkingService.parseAsync(sourceText); + + // 2. 如果结构识别失败,降级到按字数分块 + if (!chunkingResult.success || chunkingResult.chunks.length === 0) { + logger.info(`[CourseGenerationService] 未检测到章级结构,降级到按字数分块`); + return this.generateWithFallbackChunking(adapter, sourceText, prompt, taskId, courseId, modelId); + } + + const contentChunks = chunkingResult.chunks; + logger.info(`[CourseGenerationService] 按章分块成功: pattern=${chunkingResult.pattern}, chunks=${contentChunks.length}`); + await taskService.updateStatus(taskId, 'content_generating', 15, `按章分块完成,共 ${contentChunks.length} 个块`); + + // 3. 并行生成每个分块 + 失败重试一次 + const generators = contentChunks.map((chunk, i) => + () => this.generateChunkWithContext(adapter, chunk, prompt, taskId, courseId, i, modelId) + ); + + const results = await this.parallelWithRetry(generators, taskId); + + logger.info(`[CourseGenerationService] 结构分块并行生成完成: 共 ${results.length} 个分块`); + + // 4. 合并结果 + return mergeResults(results); + } + + /** + * 降级的按字数分块生成(当结构识别失败时使用) + */ + private async generateWithFallbackChunking( + adapter: any, + sourceText: string, + prompt: string, + taskId: string, + courseId: string, + modelId: string + ): Promise { + if (sourceText.length <= CHUNK_SIZE) { + // 单次生成 + return this.generateOnce(adapter, sourceText, prompt, taskId, courseId, undefined, modelId); + } + + // 按字数分块 + const chunks = chunkingByCode(sourceText, CHUNK_SIZE); + logger.info(`[CourseGenerationService] 降级分块: 文档已切为 ${chunks.length} 段`); + + await taskService.updateStatus(taskId, 'content_generating', 15, `正在并行生成 ${chunks.length} 段`); + + // 并行生成 + 失败重试一次 + const generators = chunks.map((chunk, i) => + () => this.generateOnce(adapter, chunk, prompt, taskId, courseId, i, modelId) + ); + + const results = await this.parallelWithRetry(generators, taskId); + + logger.info(`[CourseGenerationService] 降级分块并行生成完成: 共 ${results.length} 个分块`); + + return mergeResults(results); + } + + /** + * 为单个结构分块生成内容(带上下文信息) + */ + private async generateChunkWithContext( + adapter: any, + chunk: Chunk, + prompt: string, + taskId: string, + courseId: string, + chunkIndex: number, + modelId: string + ): Promise { + // 构建带章节标题的内容 + const contextualContent = `【${chunk.title}】\n\n${chunk.content}`; + return this.generateOnce(adapter, contextualContent, prompt, taskId, courseId, chunkIndex, modelId); + } + + /** + * 单次生成(不重试) + */ + private async generateOnce( + adapter: any, + sourceText: string, + prompt: string, + taskId: string, + courseId: string, + chunkIndex?: number, + modelId?: string + ): Promise { + const meta = { taskId, courseId, chunkIndex, modelId }; + return await adapter.generateCourseContent(sourceText, prompt, meta); + } + + /** + * 异步生成知识点摘要并写入 Course.accumulatedSummary + * 静默执行,失败不影响主流程 + */ + private async generateAndSaveSummary(courseId: string, outline: Outline): Promise { + try { + // 1. 构建课程内容文本(章节标题 + 节点标题 + 概要) + const contentLines: string[] = []; + for (const chapter of outline.chapters) { + if (chapter.title) contentLines.push(`## ${chapter.title}`); + for (const node of chapter.nodes) { + contentLines.push(`- ${node.title}${node.suggestedContent ? ':' + node.suggestedContent : ''}`); + } + } + const courseContent = contentLines.join('\n'); + + // 2. 读取父课程的已有摘要(如果是续旧课) + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { parentCourseId: true }, + }); + let existingSummary = ''; + if (course?.parentCourseId) { + const parentCourse = await prisma.course.findUnique({ + where: { id: course.parentCourseId }, + select: { accumulatedSummary: true }, + }); + existingSummary = parentCourse?.accumulatedSummary || ''; + } + + // 3. 获取摘要 Prompt 并替换变量 + let summaryPrompt = await getPromptTemplate('course_summary'); + summaryPrompt = summaryPrompt.replace(/\{\{existing_summary\}\}/g, existingSummary || '(无,这是第一次生成)'); + + // 4. 调用 AI 生成摘要(使用短文本补全,不需要 JSON 格式) + const adapter = ModelProviderFactory.create('doubao'); + const summaryText = await adapter.generateShortCompletion(summaryPrompt, courseContent); + + // 5. 截断到 1000 字以内 + const truncatedSummary = summaryText && summaryText.length > 1000 + ? summaryText.slice(0, 1000) + : summaryText; + + // 6. 写入数据库 + await prisma.course.update({ + where: { id: courseId }, + data: { accumulatedSummary: truncatedSummary || null }, + }); + + logger.info(`[CourseGenerationService] 知识点摘要生成完成: courseId=${courseId}, length=${truncatedSummary?.length || 0}`); + } catch (err: any) { + logger.warn(`[CourseGenerationService] 知识点摘要生成异常: courseId=${courseId}, error=${err?.message}`); + // 静默失败,不抛错 + } + } +} + +export const courseGenerationService = new CourseGenerationService(); diff --git a/backend/src/services/coverImageService.ts b/backend/src/services/coverImageService.ts new file mode 100644 index 0000000..6f25567 --- /dev/null +++ b/backend/src/services/coverImageService.ts @@ -0,0 +1,146 @@ +/** + * 课程封面生成服务 + * 根据课程标题和风格生成品牌化的渐变封面图片 + */ + +import { createCanvas, CanvasRenderingContext2D } from 'canvas'; +import { logger } from '../utils/logger'; +import ossService from './ossService'; + +// ✅ 与前端 DesignSystem.courseCardGradients 保持一致(6 色:品牌蓝 + 多邻国绿/紫/红/黄/橙) +const BRAND_GRADIENTS: Array<[string, string]> = [ + ['#2266FF', '#4080FF'], // 品牌蓝 + ['#58CC02', '#89E219'], // Duolingo Feather Green + ['#8A4FFF', '#A870FF'], // 电光紫 Cyber Iris + ['#FF4B4B', '#FF6B6B'], // Duolingo Cardinal Red + ['#FFC800', '#FFE040'], // Duolingo Bee Yellow + ['#FF9600', '#FFB340'], // Duolingo Fox Orange +]; + +// 风格图标映射(用于底纹装饰) +const STYLE_ICONS: Record = { + 'full': 'book.closed.fill', + 'essence': 'lightbulb.fill', + 'one-page': 'doc.text.fill', +}; + +/** + * 根据课程ID计算哈希值,选择固定的渐变组合 + * 确保同一课程每次生成的封面颜色一致(与前端 ProfileCourseCard 逻辑一致) + */ +function selectGradient(courseId: string): [string, string] { + // ✅ 使用与前端相同的哈希算法(基于课程ID) + let hash = 0; + for (let i = 0; i < courseId.length; i++) { + hash = ((hash * 31) + courseId.charCodeAt(i)) & 0x7fffffff; + } + const index = Math.abs(hash) % BRAND_GRADIENTS.length; + return BRAND_GRADIENTS[index]; +} + +/** + * 文字换行处理(最多3行) + */ +function wrapText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, + fontSize: number +): string[] { + const words = text.split(''); + const lines: string[] = []; + let currentLine = ''; + + for (const char of words) { + const testLine = currentLine + char; + const metrics = ctx.measureText(testLine); + if (metrics.width > maxWidth && currentLine.length > 0) { + lines.push(currentLine); + currentLine = char; + if (lines.length >= 3) break; // 最多3行 + } else { + currentLine = testLine; + } + } + if (currentLine.length > 0 && lines.length < 3) { + lines.push(currentLine); + } + return lines; +} + +/** + * 生成课程封面图片 + * @param courseId 课程ID + * @param title 课程标题 + * @param style 生成风格 ('full' | 'essence' | 'one-page') + * @returns 封面图片的OSS完整URL(如 'https://upolar.oss-cn-shanghai.aliyuncs.com/covers/xxx.png') + */ +export async function generateCourseCover( + courseId: string, + title: string, + style: 'full' | 'essence' | 'one-page' = 'full' +): Promise { + try { + // 1. 选择渐变颜色(基于课程ID哈希,确保与前端 ProfileCourseCard 一致) + const [color1, color2] = selectGradient(courseId); + + // 2. 创建画布 (3:4 比例) + const width = 600; + const height = 800; + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, width, height); + + // 3. 绘制渐变背景(与前端 ProfileCourseCard 一致,不绘制标题) + const gradient = ctx.createLinearGradient(0, 0, width, height); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + + // 4. 生成图片 Buffer + // ✅ 使用 PNG 格式,避免 JPEG 压缩可能产生的水印或文字 + const buffer = canvas.toBuffer('image/png'); + + // 5. 定义 OSS 对象名称(路径) + const objectName = `covers/${courseId}.png`; + + // 6. 删除 OSS 上的旧封面图(如果存在) + try { + // 删除 PNG 文件 + const exists = await ossService.objectExists(objectName); + if (exists) { + await ossService.deleteObject(objectName); + logger.info(`[CoverImage] 已删除OSS上的旧封面图: ${courseId}`); + } + } catch (error: any) { + logger.warn(`[CoverImage] 删除OSS旧封面图失败: ${courseId}`, error); + // 继续执行,不影响新文件上传 + } + + // 同时删除旧的 JPG 文件(如果存在) + try { + const oldJpgObjectName = `covers/${courseId}.jpg`; + const exists = await ossService.objectExists(oldJpgObjectName); + if (exists) { + await ossService.deleteObject(oldJpgObjectName); + } + } catch (error: any) { + // 忽略错误 + } + + // 7. 上传到 OSS + await ossService.putBuffer(objectName, buffer, { + contentType: 'image/png', + }); + + // 8. 获取 OSS 访问 URL + const ossUrl = ossService.getObjectUrl(objectName); + logger.info(`[CoverImage] 封面生成并上传OSS成功: ${courseId}, URL: ${ossUrl}`); + return ossUrl; + } catch (error: any) { + logger.error(`[CoverImage] 封面生成失败: ${courseId}`, error); + // 返回空字符串,前端可以显示占位图 + return ''; + } +} diff --git a/backend/src/services/documentParserService.ts b/backend/src/services/documentParserService.ts new file mode 100644 index 0000000..bf013ba --- /dev/null +++ b/backend/src/services/documentParserService.ts @@ -0,0 +1,251 @@ +/** + * 文档解析服务 + * 支持 PDF、Word 和 EPUB 文档的文本提取 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import pdfParse from 'pdf-parse'; +import mammoth from 'mammoth'; +import EPub from 'epub'; +import { logger } from '../utils/logger'; +import { CustomError } from '../middleware/errorHandler'; + +export class DocumentParserService { + /** + * 清理 HTML 标签,将 XHTML 转换为纯文本 + * 保留合理的换行结构 + */ + private stripHtmlTags(html: string): string { + return html + // 块级元素转换为换行 + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<\/div>/gi, '\n') + .replace(/<\/h[1-6]>/gi, '\n') + .replace(/<\/li>/gi, '\n') + .replace(/<\/tr>/gi, '\n') + .replace(/<\/blockquote>/gi, '\n') + // 移除所有其他 HTML 标签 + .replace(/<[^>]+>/g, '') + // 解码常见 HTML 实体 + .replace(/ /gi, ' ') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/&/gi, '&') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/gi, "'") + // 解码数字实体(常见的) + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + // 清理多余空白 + .replace(/[ \t]+/g, ' ') // 多个空格/制表符合并 + .replace(/ \n/g, '\n') // 行末空格 + .replace(/\n /g, '\n') // 行首空格 + .replace(/\n{3,}/g, '\n\n') // 多个换行合并 + .trim(); + } + + /** + * 解析 PDF 文件,提取文本内容 + */ + async parsePDF(filePath: string): Promise { + try { + logger.info(`[DocumentParser] 开始解析 PDF: ${filePath}`); + const startTime = Date.now(); + + const dataBuffer = await fs.readFile(filePath); + const data = await pdfParse(dataBuffer); + + const duration = Date.now() - startTime; + let text = data.text.trim(); + + // 清理 null 字节(0x00),PostgreSQL 不支持 + text = text.replace(/\0/g, ''); + + if (!text || text.length === 0) { + throw new CustomError('PDF 文件未包含可提取的文本内容(可能是扫描版图片 PDF)', 400); + } + + logger.info(`[DocumentParser] PDF 解析完成: ${text.length} 字符, 耗时: ${duration}ms`); + return text; + } catch (error: any) { + logger.error(`[DocumentParser] PDF 解析失败: ${error.message}`, error); + if (error instanceof CustomError) { + throw error; + } + throw new CustomError(`PDF 解析失败: ${error.message}`, 500); + } + } + + /** + * 解析 Word 文件(.docx),提取文本内容 + */ + async parseWord(filePath: string): Promise { + try { + logger.info(`[DocumentParser] 开始解析 Word: ${filePath}`); + const startTime = Date.now(); + + const dataBuffer = await fs.readFile(filePath); + const result = await mammoth.extractRawText({ buffer: dataBuffer }); + + const duration = Date.now() - startTime; + let text = result.value.trim(); + + // 清理 null 字节(0x00),PostgreSQL 不支持 + text = text.replace(/\0/g, ''); + + // 清理其他可能导致 JSON 解析问题的控制字符 + // 保留换行符和制表符,但清理其他控制字符 + text = text.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, ''); + + // 规范化换行符(统一为 \n) + text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + if (!text || text.length === 0) { + throw new CustomError('Word 文件未包含可提取的文本内容', 400); + } + + // mammoth 可能会返回警告(如未支持的格式),记录但不影响使用 + if (result.messages.length > 0) { + logger.warn(`[DocumentParser] Word 解析警告: ${JSON.stringify(result.messages)}`); + } + + logger.info(`[DocumentParser] Word 解析完成: ${text.length} 字符, 耗时: ${duration}ms`); + return text; + } catch (error: any) { + logger.error(`[DocumentParser] Word 解析失败: ${error.message}`, error); + if (error instanceof CustomError) { + throw error; + } + throw new CustomError(`Word 解析失败: ${error.message}`, 500); + } + } + + /** + * 解析 EPUB 文件,提取纯文本内容(忽略图片、字体、CSS等资源) + */ + async parseEPUB(filePath: string): Promise { + return new Promise((resolve, reject) => { + try { + logger.info(`[DocumentParser] 开始解析 EPUB: ${filePath}`); + const startTime = Date.now(); + + const epub = new EPub(filePath); + + epub.on('end', async () => { + try { + const chapters: string[] = []; + + // 获取所有章节 + const flow = epub.flow || []; + + // 按顺序提取每个章节的文本 + for (const chapterItem of flow) { + try { + // getChapter 返回 XHTML 原文,需要手动清理 HTML 标签 + let chapterText = await new Promise((chapterResolve, chapterReject) => { + epub.getChapter(chapterItem.id, (error: Error | null, text: string) => { + if (error) { + logger.warn(`[DocumentParser] 章节 ${chapterItem.id} 解析失败: ${error.message}`); + chapterResolve(''); // 跳过失败的章节 + } else { + chapterResolve(text || ''); + } + }); + }); + + if (chapterText && chapterText.trim()) { + // 清理 HTML 标签,保留纯文本 + chapterText = this.stripHtmlTags(chapterText); + if (chapterText.trim()) { + chapters.push(chapterText.trim()); + } + } + } catch (chapterError: any) { + logger.warn(`[DocumentParser] 章节 ${chapterItem.id} 处理失败: ${chapterError.message}`); + // 继续处理其他章节 + } + } + + if (chapters.length === 0) { + throw new CustomError('EPUB 文件未包含可提取的文本内容', 400); + } + + // 合并所有章节文本 + let fullText = chapters.join('\n\n'); + + // 清理 null 字节和控制字符 + fullText = fullText.replace(/\0/g, ''); + fullText = fullText.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, ''); + fullText = fullText.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + // 清理多余的空白行(保留段落间的单个空行) + fullText = fullText.replace(/\n{3,}/g, '\n\n').trim(); + + const duration = Date.now() - startTime; + logger.info(`[DocumentParser] EPUB 解析完成: ${chapters.length} 章, ${fullText.length} 字符, 耗时: ${duration}ms`); + + resolve(fullText); + } catch (error: any) { + reject(error); + } + }); + + epub.on('error', (error: Error) => { + logger.error(`[DocumentParser] EPUB 解析失败: ${error.message}`, error); + reject(new CustomError(`EPUB 解析失败: ${error.message}`, 500)); + }); + + // 开始解析 + epub.parse(); + } catch (error: any) { + logger.error(`[DocumentParser] EPUB 文件打开失败: ${error.message}`, error); + reject(new CustomError(`EPUB 解析失败: ${error.message}`, 500)); + } + }); + } + + /** + * 根据文件扩展名自动选择解析方法 + */ + async extractText(filePath: string, mimeType: string): Promise { + const ext = path.extname(filePath).toLowerCase(); + + // PDF 文件 + if (ext === '.pdf' || mimeType === 'application/pdf') { + return this.parsePDF(filePath); + } + + // Word 文件(.docx) + if (ext === '.docx' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + return this.parseWord(filePath); + } + + // Word 文件(.doc)- 旧格式,mammoth 不支持,需要提示 + if (ext === '.doc' || mimeType === 'application/msword') { + throw new CustomError('不支持 .doc 格式,请转换为 .docx 格式后重试', 400); + } + + // EPUB 文件 + if (ext === '.epub' || mimeType === 'application/epub+zip' || mimeType === 'application/epub') { + return this.parseEPUB(filePath); + } + + throw new CustomError(`不支持的文件格式: ${ext}`, 400); + } + + /** + * 清理临时文件 + */ + async cleanupFile(filePath: string): Promise { + try { + await fs.unlink(filePath); + logger.info(`[DocumentParser] 已清理临时文件: ${filePath}`); + } catch (error: any) { + logger.warn(`[DocumentParser] 清理临时文件失败: ${filePath}, ${error.message}`); + } + } +} + +export const documentParserService = new DocumentParserService(); diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts new file mode 100644 index 0000000..fc14c9e --- /dev/null +++ b/backend/src/services/index.ts @@ -0,0 +1,14 @@ +/** + * 服务导出 + */ + +import { taskService } from './taskService'; +import { contentService } from './contentService'; +import { documentParserService } from './documentParserService'; +import { structureChunkingService } from './structureChunkingService'; + +export { + taskService, + contentService, + structureChunkingService, +}; diff --git a/backend/src/services/modelProvider.ts b/backend/src/services/modelProvider.ts new file mode 100644 index 0000000..1992159 --- /dev/null +++ b/backend/src/services/modelProvider.ts @@ -0,0 +1,315 @@ +/** + * 模型提供商抽象层 + * 支持多种 AI 模型,目前实现豆包 + */ + +import OpenAI from 'openai'; +import axios from 'axios'; +import { ChapteredResponse } from '../types/ai'; +import { logger } from '../utils/logger'; +import { logBookAiCall } from './promptConfigService'; +import { date } from 'joi'; + +// 豆包配置 +const DOUBAO_API_KEY = process.env.DOUBAO_API_KEY || 'a3e13a85-437f-448c-aaa9-14292cd5e0ab'; +const DOUBAO_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'; +/** 当前默认模型(豆包 Flash) */ +export const DOUBAO_MODEL = 'doubao-seed-1-6-flash-250828'; +/** 豆包 Lite,供「直接测试-豆包lite」人格对比使用 */ +export const DOUBAO_MODEL_LITE = 'doubao-seed-1-6-lite-251015'; + +/** + * 豆包模型适配器 + * 支持书籍解析(一步生成) + */ +export class DoubaoAdapter { + private client: OpenAI; + private timeout: number = 10 * 60 * 1000; // 10分钟超时 + + constructor() { + this.client = new OpenAI({ + apiKey: DOUBAO_API_KEY, + baseURL: DOUBAO_BASE_URL, + }); + } + + /** + * 解析 Volces/豆包 API 响应内容(用于 generateShortCompletion 的 axios 响应) + */ + private parseResponseContent(responseData: any): string { + if (typeof responseData === 'string') { + return responseData; + } + // /responses API 返回格式:output 数组可能包含 reasoning(推理过程)和 message(实际回复) + // 优先找 type="message" 的 output,其中 content 数组里的 output_text 是最终回复 + if (responseData?.output && Array.isArray(responseData.output) && responseData.output.length > 0) { + // 优先找 message 类型的 output(跳过 reasoning) + const messageOutput = responseData.output.find((o: any) => o.type === 'message'); + const targetOutput = messageOutput || responseData.output[0]; + if (targetOutput?.content && Array.isArray(targetOutput.content) && targetOutput.content.length > 0) { + const textContent = targetOutput.content.find((item: any) => item.type === 'output_text'); + if (textContent?.text) return textContent.text; + if (targetOutput.content[0]?.text) return targetOutput.content[0].text; + } + } + if (responseData?.content) return responseData.content; + if (responseData?.choices?.[0]?.message?.content) return responseData.choices[0].message.content; + return typeof responseData === 'object' ? JSON.stringify(responseData) : String(responseData); + } + + /** + * 课程生成(支持三种模式) + * 接收完整的systemPrompt(已替换persona变量),用户消息为原始材料。 + * meta 可选:taskId/courseId/chunkIndex 写 book_ai_call_log;modelId 覆盖本次使用的模型(如豆包 lite)。 + */ + async generateCourseContent( + sourceText: string, + systemPrompt: string, + meta?: { taskId?: string; courseId?: string; chunkIndex?: number; modelId?: string } + ): Promise { + const systemContent = systemPrompt.replace(/\{\{sourceText\}\}/g, '见下文'); + const promptForLog = systemContent + '\n\n--- 用户消息(原始材料)---\n\n' + sourceText; + const modelId = DOUBAO_MODEL_LITE; + + let content: string | undefined; + const startTime = Date.now(); + + try { + logger.info(`[DoubaoAdapter] 开始课程生成,使用模型: ${modelId}`); + const input = `${systemContent}\n\n${sourceText}`; + const response = await Promise.race([ + axios.post( + `${DOUBAO_BASE_URL}/responses`, + { + model: modelId, + input, + thinking: { type: 'disabled' }, + max_output_tokens: 65535, + temperature: 0.7, + top_p: 0.7, + reasoning: { effort: 'minimal' }, + }, + { + headers: { + 'Authorization': `Bearer ${DOUBAO_API_KEY}`, + 'Content-Type': 'application/json', + }, + timeout: this.timeout, + } + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('课程生成超时')), this.timeout) + ), + ]); + + const durationMs = Date.now() - startTime; + const responseData = (response as any).data; + content = this.parseResponseContent(responseData); + + if (!content) { + throw new Error('AI 返回内容为空'); + } + + logger.info(`[DoubaoAdapter] 课程生成完成,耗时: ${durationMs}ms`); + logger.debug(`[DoubaoAdapter] AI 返回内容长度: ${content.length} 字符`); + logger.debug(`[DoubaoAdapter] AI 返回内容前500字符: ${content.substring(0, 500)}`); + + let jsonStr = content.trim(); + + // 移除代码块标记 + if (jsonStr.startsWith('```json')) { + jsonStr = jsonStr.replace(/^```json\n?/, '').replace(/\n?```$/, ''); + } else if (jsonStr.startsWith('```')) { + jsonStr = jsonStr.replace(/^```\n?/, '').replace(/\n?```$/, ''); + } + + jsonStr = jsonStr.trim(); + + // 若豆包返回了 Markdown/纯文本(如 # 标题、- 列表)而非 JSON,则无法解析,抛错以触发重试 + const firstBrace = jsonStr.indexOf('{'); + if (firstBrace === -1) { + logger.warn(`[DoubaoAdapter] AI 返回了非 JSON 格式(未找到 {),疑似 Markdown 或纯文本,前 200 字符: ${jsonStr.substring(0, 200)}`); + throw new Error('AI 返回了非 JSON 格式(可能为 Markdown 或课程总结),无法解析。请重试。'); + } + if (firstBrace > 0) { + jsonStr = jsonStr.substring(firstBrace); + logger.warn(`[DoubaoAdapter] 已去掉 JSON 前的 ${firstBrace} 个字符`); + } + + // 如果 JSON 被截断(以不完整的字符串结尾),尝试修复 + if (jsonStr.endsWith('"') === false && jsonStr.includes('"')) { + // 检查是否有未闭合的字符串 + const lastQuoteIndex = jsonStr.lastIndexOf('"'); + const beforeLastQuote = jsonStr.substring(0, lastQuoteIndex); + const escapedQuotes = (beforeLastQuote.match(/\\"/g) || []).length; + // 如果最后一个引号前有奇数个转义引号,说明字符串可能未闭合 + if (escapedQuotes % 2 === 0) { + logger.warn(`[DoubaoAdapter] 检测到可能被截断的 JSON,尝试修复...`); + // 尝试找到最后一个完整的对象/数组并截断 + const lastCompleteBrace = Math.max( + jsonStr.lastIndexOf('}'), + jsonStr.lastIndexOf(']') + ); + if (lastCompleteBrace > 0) { + jsonStr = jsonStr.substring(0, lastCompleteBrace + 1); + logger.warn(`[DoubaoAdapter] 已截断到位置 ${lastCompleteBrace}`); + } + } + } + + let parsed; + try { + parsed = JSON.parse(jsonStr); + } catch (parseError: any) { + // 尝试修复 "Bad control character":AI 在 JSON 字符串值中返回了裸 \n \r \t 等控制字符 + if (parseError.message.includes('control character') || parseError.message.includes('Bad control')) { + logger.warn(`[DoubaoAdapter] 检测到控制字符问题,尝试清理后重新解析...`); + // 安全替换:在 JSON 字符串内部(引号包裹区域),将裸控制字符替换为转义序列 + const sanitized = jsonStr.replace(/"(?:[^"\\]|\\.)*"/g, (match) => { + return match.replace(/[\x00-\x1F\x7F]/g, (ch) => { + switch (ch) { + case '\n': return '\\n'; + case '\r': return '\\r'; + case '\t': return '\\t'; + default: return ''; + } + }); + }); + try { + parsed = JSON.parse(sanitized); + logger.info(`[DoubaoAdapter] 控制字符清理后 JSON 解析成功`); + } catch (retryError: any) { + logger.error(`[DoubaoAdapter] 清理后仍解析失败: ${retryError.message}`); + logger.error(`[DoubaoAdapter] JSON 字符串长度: ${jsonStr.length}`); + logger.error(`[DoubaoAdapter] 完整 JSON 字符串(前2000字符): ${jsonStr.substring(0, 2000)}`); + throw new Error(`JSON 解析失败: ${parseError.message}。请检查 AI 返回的内容是否完整。`); + } + } else { + // 记录详细的解析错误信息 + logger.error(`[DoubaoAdapter] JSON 解析失败: ${parseError.message}`); + logger.error(`[DoubaoAdapter] JSON 字符串长度: ${jsonStr.length}`); + logger.error(`[DoubaoAdapter] JSON 字符串位置 ${parseError.message.match(/position (\d+)/)?.[1] || 'unknown'} 附近的内容: ${jsonStr.substring(Math.max(0, parseInt(parseError.message.match(/position (\d+)/)?.[1] || '0') - 100), parseInt(parseError.message.match(/position (\d+)/)?.[1] || '0') + 100)}`); + logger.error(`[DoubaoAdapter] 完整 JSON 字符串(前2000字符): ${jsonStr.substring(0, 2000)}`); + throw new Error(`JSON 解析失败: ${parseError.message}。请检查 AI 返回的内容是否完整。`); + } + } + + // ✅ 所有模式统一格式,直接验证chaptered_content + if (!('chaptered_content' in parsed) || !Array.isArray(parsed.chaptered_content)) { + throw new Error('返回格式错误:必须包含 chaptered_content 数组'); + } + + const courseContent = parsed as ChapteredResponse; + + // 验证格式 + for (const chapter of courseContent.chaptered_content) { + if (!chapter.sections || !Array.isArray(chapter.sections)) { + throw new Error('返回格式错误:章节格式不正确,应包含 sections 数组(parent_title 单层时可为空字符串)'); + } + for (const section of chapter.sections) { + if (!section.section_title || !section.section_interpretation) { + throw new Error('返回格式错误:小节格式不正确,应包含 section_title 和 section_interpretation'); + } + } + } + + logger.info(`[DoubaoAdapter] chaptered_content 格式,共 ${courseContent.chaptered_content.length} 个章节`); + + const finalDurationMs = Date.now() - startTime; + + if (meta?.taskId) { + logBookAiCall({ + taskId: meta.taskId, + courseId: meta.courseId, + chunkIndex: meta.chunkIndex, + prompt: promptForLog, + response: content, + status: 'success', + durationMs: finalDurationMs, + }).catch(() => {}); + } + + return courseContent; + } catch (error: any) { + const durationMs = Date.now() - startTime; + if (meta?.taskId) { + logBookAiCall({ + taskId: meta.taskId, + courseId: meta.courseId, + chunkIndex: meta.chunkIndex, + prompt: promptForLog, + response: content ?? null, + status: 'failed', + errorMessage: error?.message, + durationMs, + }).catch(() => {}); + } + logger.error(`[DoubaoAdapter===========] 课程生成失败: ${error.message}`, error); + throw new Error(`课程生成失败===================: ${error.message}`); + } + } + + /** + * 短文本补全(如课程标题生成) + * modelId 可选,不传则用默认 DOUBAO_MODEL。 + */ + async generateShortCompletion( + systemPrompt: string, + userContent: string, + modelId?: string + ): Promise { + const model = DOUBAO_MODEL_LITE; + const input = `${systemPrompt}\n\n${userContent}`; + try { + const response = await Promise.race([ + axios.post( + `${DOUBAO_BASE_URL}/responses`, + { + model, + input, + thinking: { type: 'disabled' }, + max_output_tokens: 32768, + temperature: 0.5, + top_p: 0.7, + reasoning: { + effort: "minimal" + } + }, + { + headers: { + 'Authorization': `Bearer ${DOUBAO_API_KEY}`, + 'Content-Type': 'application/json', + }, + timeout: 60000, + } + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('短文本补全超时')), 60000) + ), + ]); + console.log('respons=======================', response); + const responseData = (response as any).data; + const content = this.parseResponseContent(responseData); + if (!content) throw new Error('AI 返回内容为空'); + return content.trim(); + } catch (error: any) { + const errorMsg = error?.response?.data?.message || error?.message || 'Unknown error'; + logger.error(`[DoubaoAdapter] 短文本补全失败: ${errorMsg}`); + throw new Error(`短文本补全失败: ${errorMsg}`); + } + } +} + +/** + * 模型提供商工厂 + */ +export class ModelProviderFactory { + static create(provider: string = 'doubao'): DoubaoAdapter { + switch (provider) { + case 'doubao': + return new DoubaoAdapter(); + default: + throw new Error(`未知的模型提供商: ${provider}`); + } + } +} diff --git a/backend/src/services/ossService.ts b/backend/src/services/ossService.ts new file mode 100644 index 0000000..2f93f70 --- /dev/null +++ b/backend/src/services/ossService.ts @@ -0,0 +1,140 @@ +/** + * 阿里云OSS服务层 - 处理文件上传相关业务逻辑 + */ +import OSS from 'ali-oss'; +import { config } from '../config/index'; +import path from 'path'; +import fs from 'fs'; + +export class OssService { + private client: OSS; + + constructor() { + this.client = new OSS({ + accessKeyId: config.aliyun.accessKeyId, + accessKeySecret: config.aliyun.accessKeySecret, + region: config.aliyun.region, + authorizationV4: true, + bucket: config.aliyun.bucket, + endpoint: config.aliyun.endpoint, + }); + } + + /** + * 上传文件到OSS + * @param objectName - OSS对象名称(文件路径) + * @param filePath - 本地文件路径 + * @param options - 上传选项(headers等) + * @returns Promise + */ + async putObject( + objectName: string, + filePath: string, + options?: { + headers?: Record; + contentType?: string; + } + ): Promise { + try { + const normalizedPath = path.normalize(filePath); + + // 检查文件是否存在 + if (!fs.existsSync(normalizedPath)) { + throw new Error(`文件不存在: ${normalizedPath}`); + } + + const putOptions: OSS.PutObjectOptions = {}; + + if (options?.headers) { + putOptions.headers = options.headers; + } + + if (options?.contentType) { + putOptions.mime = options.contentType; + } + + const result = await this.client.put(objectName, normalizedPath, putOptions); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`上传文件到OSS失败: ${errorMessage}`); + } + } + + /** + * 上传Buffer到OSS + * @param objectName - OSS对象名称(文件路径) + * @param buffer - 文件Buffer + * @param options - 上传选项(headers等) + * @returns Promise + */ + async putBuffer( + objectName: string, + buffer: Buffer, + options?: { + headers?: Record; + contentType?: string; + } + ): Promise { + try { + const putOptions: OSS.PutObjectOptions = {}; + + if (options?.headers) { + putOptions.headers = options.headers; + } + + if (options?.contentType) { + putOptions.mime = options.contentType; + } + + const result = await this.client.put(objectName, buffer, putOptions); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`上传Buffer到OSS失败: ${errorMessage}`); + } + } + + /** + * 获取文件的访问URL + * @param objectName - OSS对象名称 + * @returns 文件访问URL + */ + getObjectUrl(objectName: string): string { + return `${config.aliyun.domain}/${objectName}`; + } + + /** + * 删除OSS对象 + * @param objectName - OSS对象名称 + * @returns Promise + */ + async deleteObject(objectName: string): Promise { + try { + const result = await this.client.delete(objectName); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`删除OSS对象失败: ${errorMessage}`); + } + } + + /** + * 检查对象是否存在 + * @param objectName - OSS对象名称 + * @returns Promise + */ + async objectExists(objectName: string): Promise { + try { + await this.client.head(objectName); + return true; + } catch (error: any) { + if (error.status === 404) { + return false; + } + throw error; + } + } +} + +export default new OssService(); diff --git a/backend/src/services/promptConfigService.ts b/backend/src/services/promptConfigService.ts new file mode 100644 index 0000000..25c19c7 --- /dev/null +++ b/backend/src/services/promptConfigService.ts @@ -0,0 +1,654 @@ +/** + * Prompt 配置服务 + * 从 AppConfig 表读写各类 Prompt 模板与模型配置 + */ + +import prisma from '../utils/prisma'; +import { logger } from '../utils/logger'; + +// Prompt类型 +export type PromptType = 'text_parse' | 'direct_generation' | 'continue_course' | 'course_title' | 'course_summary' + | 'direct_generation_lite' | 'direct_generation_lite_outline' | 'direct_generation_lite_summary' + | 'text_parse_xiaohongshu' | 'text_parse_xiaolin' | 'text_parse_douyin' + | 'continue_course_xiaohongshu' | 'continue_course_xiaolin' | 'continue_course_douyin'; + +// AppConfig Key +export const PROMPT_KEYS = { + TEXT_PARSE: 'text_parse_prompt', + DIRECT_GENERATION: 'direct_generation_prompt', + CONTINUE_COURSE: 'continue_course_prompt', + COURSE_TITLE: 'course_title_prompt', + COURSE_SUMMARY: 'course_summary_prompt', + DIRECT_GENERATION_LITE: 'direct_generation_lite_prompt', + DIRECT_GENERATION_LITE_OUTLINE: 'direct_generation_lite_outline_prompt', + DIRECT_GENERATION_LITE_SUMMARY: 'direct_generation_lite_summary_prompt', + // 文本解析三个独立 Prompt + TEXT_PARSE_XIAOHONGSHU: 'text_parse_xiaohongshu_prompt', + TEXT_PARSE_XIAOLIN: 'text_parse_xiaolin_prompt', + TEXT_PARSE_DOUYIN: 'text_parse_douyin_prompt', + // 续旧课三个独立 Prompt + CONTINUE_COURSE_XIAOHONGSHU: 'continue_course_xiaohongshu_prompt', + CONTINUE_COURSE_XIAOLIN: 'continue_course_xiaolin_prompt', + CONTINUE_COURSE_DOUYIN: 'continue_course_douyin_prompt', +} as const; + +// 模型配置 Key(每个 Prompt 可单独配置模型) +export const MODEL_CONFIG_KEYS = { + TEXT_PARSE_XIAOHONGSHU: 'text_parse_xiaohongshu_model', + TEXT_PARSE_XIAOLIN: 'text_parse_xiaolin_model', + TEXT_PARSE_DOUYIN: 'text_parse_douyin_model', + DIRECT_GENERATION_LITE: 'direct_generation_lite_model', + DIRECT_GENERATION_LITE_OUTLINE: 'direct_generation_lite_outline_model', + DIRECT_GENERATION_LITE_SUMMARY: 'direct_generation_lite_summary_model', + CONTINUE_COURSE_XIAOHONGSHU: 'continue_course_xiaohongshu_model', + CONTINUE_COURSE_XIAOLIN: 'continue_course_xiaolin_model', + CONTINUE_COURSE_DOUYIN: 'continue_course_douyin_model', +} as const; + +// 默认文本解析Prompt(旧版,保留作为 fallback 默认值) +export const DEFAULT_TEXT_PARSE_PROMPT = `请严格按照以下步骤处理提供的文本材料,最终仅输出符合要求的 JSON 文本,无任何多余文字、注释或说明: + +### 角色定位 +你是一名专业中文编辑,擅长从长文本中精准提炼核心观点/内容/情节,能用通俗易懂的方式进行摘要讲解,帮助读者高效、有趣地掌握关键信息。 + +### 工作步骤1:判定文本的章节结构类型(快速判定,无需详细分析) +1. 核心判定铁律(强制执行,杜绝误判) +- 标题识别双条件(两个条件满足其一即可) +-- 条件一:带有 "篇 / 章 / 节 / 模块 / 单元 / 部分" 等明确层级标识的独立文字行。 +-- 条件二:无明确层级标识,但同时满足「独立成行 + 能明确统领后续一大段内容 + 与其他同级别内容块形成并列 / 隶属关系」的文字行。 + +- 正文排除铁则 +任何嵌套在段落内、仅服务于单段内容的补充性文字,无论是否带序号或分类名称,都属于正文细节,一律不纳入判定,包括但不限于:分点序号、小分类、场景示例、概念解释、括号说明、Tips / 注意点等。 + +2. 结构二选一规则:严格对照标准,只能选择其中一种结果 +【两层结构】:文本存在明确隶属关系—— 有 1 个及以上上级大分类 / 模块标题,下属多个子分类 / 子模块标题,且上下级标题均完全符合上述「标题认定标准」。 +【单层结构】:文本所有核心内容标题为平行并列关系—— 无任何上下级隶属层级,各内容单元标题地位完全平等,且均完全符合上述「标题认定标准」。 +*注:若原文没有明确的章节/二级标题,则一律走单层 + +3. 判定底线:只保留高于正文子要点的层级标题,正文细节里的任何层级都与本次判定无关;判定结果仅限 "两层结构" 或 "单层结构",不允许出现其他结构表述。 + +### 工作步骤2:根据判定结果执行对应生成规则 +===== 【规则A:判定为两层结构时执行】 ===== +1. 直接提取文本中的「上级分类名称」(原文自带的一级标题)和下属的「子模块标题」(原文自带的二级标题),**严禁自行提炼标题,只保留高于子要点的层级标题, 不需要所有正文细节内容里的层级(包括分点序号、场景示例、概念解释)**; +2. 每个上级分类独立成一个对象,下属子模块归入该对象的 \`sections\` 数组; +3. 为每个子模块的核心观点生成讲解,覆盖子模块核心内容,不需要面面俱到,不添加材料外信息; +4. 格式要求:核心知识点用==高亮==标注(每子模块≤3处);结构清晰,可分段表达。 +5. 输出结构: +{ + "chaptered_content": [ + { + "parent_title": "原文一级标题", + "sections": [ + { + "section_title": "原文二级标题", + "section_interpretation": "带==高亮==的解读内容" + } + ] + } + ] +} + +===== 【规则B:判定为单层结构时执行】 ===== +1. 直接提取文本中的所有平行独立单元的 **原文自带标题**,严禁自行提炼标题,只保留高于子要点的层级标题, 不需要所有正文细节内容里的层级(包括分点序号、场景示例、概念解释); +2. 为每个单元的核心观点生成讲解,覆盖子模块核心内容,不需要面面俱到,不添加材料外信息; +3. 格式要求:核心知识点用==高亮==标注(每单元≤3处);结构清晰,可分段表达。 +4. 输出结构:与规则A 相同,使用 \`chaptered_content\`。单层时 \`chaptered_content\` 仅 1 个元素,\`parent_title\` 固定为 \`""\`,所有单元放入该元素的 \`sections\` 数组。 +{ + "chaptered_content": [ + { + "parent_title": "", + "sections": [ + { + "section_title": "原文单元标题", + "section_interpretation": "带==高亮==的解读内容" + } + ] + } + ] +} + +### 步骤3:最终强制要求 +1. 严格遵循结构字段命名,不得增减字段; +2. 标题必须完全沿用原文表述,禁止任何形式的自定义改写,禁止在标签前自行加序号; +3. 解读不遗漏核心要点; +4. **仅返回一个 JSON 对象**,以 \`{\` 开头、以 \`}\` 结尾,无任何其他内容; +5. **严禁**以 Markdown 形式输出(如 \`#\`、\`##\` 标题、\`-\` 列表、课程总结、要点归纳等),必须且仅输出 JSON,不能有前言、说明或换行后的非 JSON 内容。 + +原始材料见下文`; + +// 默认直接生成Prompt(旧版,保留作为 fallback 默认值) +export const DEFAULT_DIRECT_GENERATION_PROMPT = `你是专业课程设计师,擅长结合用户学习意图挖掘对应领域经典理论,精准拆解知识点并搭建系统课程框架。 + +### 核心要求 +1. 先精准捕捉用户输入的学习意图(明确学习目标、核心需求),快速匹配该领域3-5个核心经典理论,作为课程核心支撑; +2. 按章节组织课程,共设置至少10-20个小节,每章节围绕1个经典理论展开,每个小节仅聚焦1个具体知识点,知识点独立且有连贯性,层层递进、由浅入深; +3. 课程整体风格遵循上述讲解风格要求,小节知识点表述贴合所选风格,适配对应学习人群; +4. 格式要求:核心知识点用==高亮==标注(每小节≤3处);结构清晰,可分段表达。 +5. 最终以JSON格式输出,JSON需包含"chaptered_content"(章节数组,每个章节包含"parent_title"、"sections",sections为小节数组,每个小节包含"section_title"、"section_interpretation"); +6. 严禁冗余,知识点精准具体、可落地学习,JSON格式规范,无任何多余文字、注释,仅输出纯净JSON。 + +### 输出结构 +{ + "chaptered_content": [ + { + "parent_title": "章节标题", + "sections": [ + { + "section_title": "小节标题", + "section_interpretation": "带==高亮==的讲解内容" + } + ] + } + ] +} + +用户学习意图见下文`; + +// 默认直接测试-豆包lite Prompt(不参与 persona 替换,豆包 Lite 模型) +const _DEFAULT_LITE_BASE = `你是专业课程设计师,擅长结合用户学习意图挖掘对应领域经典理论,精准拆解知识点并搭建系统课程框架。 + +### 核心要求 +1. 先精准捕捉用户输入的学习意图(明确学习目标、核心需求),快速匹配该领域3-5个核心经典理论,作为课程核心支撑; +2. 按章节组织课程,共设置至少10-20个小节,每章节围绕1个经典理论展开,每个小节仅聚焦1个具体知识点,知识点独立且有连贯性,层层递进、由浅入深; +3. 格式要求:核心知识点用==高亮==标注(每小节≤3处);结构清晰,可分段表达; +4. 最终以JSON格式输出,JSON需包含"chaptered_content"(章节数组,每个章节包含"parent_title"、"sections",sections为小节数组,每个小节包含"section_title"、"section_interpretation"); +5. 严禁冗余,知识点精准具体、可落地学习,JSON格式规范,无任何多余文字、注释,仅输出纯净JSON。 + +### 输出结构 +{ + "chaptered_content": [ + { + "parent_title": "章节标题", + "sections": [ + { + "section_title": "小节标题", + "section_interpretation": "带==高亮==的讲解内容" + } + ] + } + ] +} + +用户学习意图见下文`; + +export const DEFAULT_DIRECT_GENERATION_LITE_PROMPT = _DEFAULT_LITE_BASE; +export const DEFAULT_DIRECT_GENERATION_LITE_OUTLINE_PROMPT = _DEFAULT_LITE_BASE; +export const DEFAULT_DIRECT_GENERATION_LITE_SUMMARY_PROMPT = _DEFAULT_LITE_BASE; + +// 默认文本解析独立 Prompt(不含 persona 变量,后台可配置) +const _DEFAULT_TEXT_PARSE_BASE = `请严格按照以下步骤处理提供的文本材料,最终仅输出符合要求的 JSON 文本,无任何多余文字、注释或说明: + +### 角色定位 +你是一名专业中文编辑,擅长从长文本中精准提炼核心观点/内容/情节,能用通俗易懂的方式进行摘要讲解,帮助读者高效、有趣地掌握关键信息。 + +### 工作步骤1:判定文本的章节结构类型(快速判定,无需详细分析) +1. 核心判定铁律(强制执行,杜绝误判) +- 标题识别双条件(两个条件满足其一即可) +-- 条件一:带有 "篇 / 章 / 节 / 模块 / 单元 / 部分" 等明确层级标识的独立文字行。 +-- 条件二:无明确层级标识,但同时满足「独立成行 + 能明确统领后续一大段内容 + 与其他同级别内容块形成并列 / 隶属关系」的文字行。 + +- 正文排除铁则 +任何嵌套在段落内、仅服务于单段内容的补充性文字,无论是否带序号或分类名称,都属于正文细节,一律不纳入判定,包括但不限于:分点序号、小分类、场景示例、概念解释、括号说明、Tips / 注意点等。 + +2. 结构二选一规则:严格对照标准,只能选择其中一种结果 +【两层结构】:文本存在明确隶属关系—— 有 1 个及以上上级大分类 / 模块标题,下属多个子分类 / 子模块标题,且上下级标题均完全符合上述「标题认定标准」。 +【单层结构】:文本所有核心内容标题为平行并列关系—— 无任何上下级隶属层级,各内容单元标题地位完全平等,且均完全符合上述「标题认定标准」。 +*注:若原文没有明确的章节/二级标题,则一律走单层 + +3. 判定底线:只保留高于正文子要点的层级标题,正文细节里的任何层级都与本次判定无关;判定结果仅限 "两层结构" 或 "单层结构",不允许出现其他结构表述。 + +### 工作步骤2:根据判定结果执行对应生成规则 +===== 【规则A:判定为两层结构时执行】 ===== +1. 直接提取文本中的「上级分类名称」(原文自带的一级标题)和下属的「子模块标题」(原文自带的二级标题),**严禁自行提炼标题,只保留高于子要点的层级标题, 不需要所有正文细节内容里的层级(包括分点序号、场景示例、概念解释)**; +2. 每个上级分类独立成一个对象,下属子模块归入该对象的 \`sections\` 数组; +3. 为每个子模块的核心观点生成讲解,覆盖子模块核心内容,不需要面面俱到,不添加材料外信息; +4. 格式要求:核心知识点用==高亮==标注(每子模块≤3处);结构清晰,可分段表达。 +5. 输出结构: +{ + "chaptered_content": [ + { + "parent_title": "原文一级标题", + "sections": [ + { + "section_title": "原文二级标题", + "section_interpretation": "带==高亮==的解读内容" + } + ] + } + ] +} + +===== 【规则B:判定为单层结构时执行】 ===== +1. 直接提取文本中的所有平行独立单元的 **原文自带标题**,严禁自行提炼标题,只保留高于子要点的层级标题, 不需要所有正文细节内容里的层级(包括分点序号、场景示例、概念解释); +2. 为每个单元的核心观点生成讲解,覆盖子模块核心内容,不需要面面俱到,不添加材料外信息; +3. 格式要求:核心知识点用==高亮==标注(每单元≤3处);结构清晰,可分段表达。 +4. 输出结构:与规则A 相同,使用 \`chaptered_content\`。单层时 \`chaptered_content\` 仅 1 个元素,\`parent_title\` 固定为 \`""\`,所有单元放入该元素的 \`sections\` 数组。 +{ + "chaptered_content": [ + { + "parent_title": "", + "sections": [ + { + "section_title": "原文单元标题", + "section_interpretation": "带==高亮==的解读内容" + } + ] + } + ] +} + +### 步骤3:最终强制要求 +1. 严格遵循结构字段命名,不得增减字段; +2. 标题必须完全沿用原文表述,禁止任何形式的自定义改写,禁止在标签前自行加序号; +3. 解读不遗漏核心要点; +4. **仅返回一个 JSON 对象**,以 \`{\` 开头、以 \`}\` 结尾,无任何其他内容; +5. **严禁**以 Markdown 形式输出(如 \`#\`、\`##\` 标题、\`-\` 列表、课程总结、要点归纳等),必须且仅输出 JSON,不能有前言、说明或换行后的非 JSON 内容。 + +原始材料见下文`; + +export const DEFAULT_TEXT_PARSE_XIAOHONGSHU_PROMPT = _DEFAULT_TEXT_PARSE_BASE; +export const DEFAULT_TEXT_PARSE_XIAOLIN_PROMPT = _DEFAULT_TEXT_PARSE_BASE; +export const DEFAULT_TEXT_PARSE_DOUYIN_PROMPT = _DEFAULT_TEXT_PARSE_BASE; + +// 默认课程摘要 Prompt(生成完成后异步调用,提取知识点摘要,≤1000字) +export const DEFAULT_COURSE_SUMMARY_PROMPT = `你是一名专业的知识点提取助手。请从以下课程内容中,提取出所有核心知识点,生成一份简洁的知识点清单。 + +要求: +1. 只列出知识点名称和一句话说明,不需要展开讲解 +2. 按主题分组,每组用【】标注主题名 +3. 总字数不超过1000字 +4. 不要输出任何额外说明,直接输出知识点清单 +5. 如果提供了"已有知识点摘要",请将新知识点与已有内容合并,去除重复,保持总字数不超过1000字 + +已有知识点摘要: +{{existing_summary}} + +本次课程内容见下文`; + +// 默认续旧课三个独立 Prompt(不含 persona 变量,后台可配置) +export const DEFAULT_CONTINUE_COURSE_XIAOHONGSHU_PROMPT = _DEFAULT_TEXT_PARSE_BASE; +export const DEFAULT_CONTINUE_COURSE_XIAOLIN_PROMPT = _DEFAULT_TEXT_PARSE_BASE; +export const DEFAULT_CONTINUE_COURSE_DOUYIN_PROMPT = _DEFAULT_TEXT_PARSE_BASE; + +// 默认续旧课Prompt(旧版,保留作为 fallback 默认值) +export const DEFAULT_CONTINUE_COURSE_PROMPT = `你是专业课程设计师,擅长拆解文本内容、捕捉用户学习主题,结合用户已学课程搭建衔接顺畅、重点突出的进阶/补充课程。 + +### 核心要求 +1. 先深度解析用户提供的文本内容,精准提炼用户正在学习的核心主题、已掌握的知识点(结合用户提及的已学课程),明确课程衔接点(补充已学盲区、延伸已学内容、进阶提升); +2. 按章节组织课程,共设置至少10-20个小节,每章节围绕主题的1个核心模块展开,每个小节仅聚焦1个具体知识点,知识点需贴合主题、衔接已学内容,逻辑连贯、循序渐进; +3. 课程整体风格遵循上述讲解风格要求,小节知识点表述贴合所选风格,适配对应学习人群,避免与已学知识点重复; +4. 格式要求:核心知识点用==高亮==标注(每小节≤3处);结构清晰,可分段表达。 +5. 最终以JSON格式输出,JSON需包含"chaptered_content"(章节数组,每个章节包含"parent_title"、"sections",sections为小节数组,每个小节包含"section_title"、"section_interpretation"); +6. 严禁冗余,知识点精准具体、可落地学习,JSON格式规范,无任何多余文字、注释,仅输出纯净JSON。 + +### 输出结构 +{ + "chaptered_content": [ + { + "parent_title": "章节标题", + "sections": [ + { + "section_title": "小节标题", + "section_interpretation": "带==高亮==的讲解内容" + } + ] + } + ] +} + +已学课程内容见下文`; + +// 默认课程标题生成 Prompt(输出 JSON key 为 course_title,对应 Course.title) +export const DEFAULT_COURSE_TITLE_PROMPT = `作为专业讲解材料命名师,按以下要求生成JSON格式标题: +1. 优先抓取材料中《》书名,无则总结《》主题名; +2. 标题仅保留「《书名/主题名》+ 解读/精讲/拆解/搞懂」结构,无冒号后内容; +3. 标题<20字,禁止出现数字、示例、深度等冗余表述; +4. 标题需带网感(简洁吸睛、符合年轻用户阅读习惯,不生硬); +5. 仅输出一行JSON,无换行、无markdown代码块、无任何说明。格式严格为:{"course_title":"你的标题"},key必须为"course_title",值为字符串。 + +材料见下文`; + +/** + * 获取Prompt类型对应的AppConfig Key + */ +function getPromptKey(type: PromptType): string { + switch (type) { + case 'text_parse': + return PROMPT_KEYS.TEXT_PARSE; + case 'direct_generation': + return PROMPT_KEYS.DIRECT_GENERATION; + case 'continue_course': + return PROMPT_KEYS.CONTINUE_COURSE; + case 'course_title': + return PROMPT_KEYS.COURSE_TITLE; + case 'course_summary': + return PROMPT_KEYS.COURSE_SUMMARY; + case 'direct_generation_lite': + return PROMPT_KEYS.DIRECT_GENERATION_LITE; + case 'direct_generation_lite_outline': + return PROMPT_KEYS.DIRECT_GENERATION_LITE_OUTLINE; + case 'direct_generation_lite_summary': + return PROMPT_KEYS.DIRECT_GENERATION_LITE_SUMMARY; + case 'text_parse_xiaohongshu': + return PROMPT_KEYS.TEXT_PARSE_XIAOHONGSHU; + case 'text_parse_xiaolin': + return PROMPT_KEYS.TEXT_PARSE_XIAOLIN; + case 'text_parse_douyin': + return PROMPT_KEYS.TEXT_PARSE_DOUYIN; + case 'continue_course_xiaohongshu': + return PROMPT_KEYS.CONTINUE_COURSE_XIAOHONGSHU; + case 'continue_course_xiaolin': + return PROMPT_KEYS.CONTINUE_COURSE_XIAOLIN; + case 'continue_course_douyin': + return PROMPT_KEYS.CONTINUE_COURSE_DOUYIN; + } +} + +/** + * 获取Prompt类型对应的默认Prompt + */ +function getDefaultPrompt(type: PromptType): string { + switch (type) { + case 'text_parse': + return DEFAULT_TEXT_PARSE_PROMPT; + case 'direct_generation': + return DEFAULT_DIRECT_GENERATION_PROMPT; + case 'continue_course': + return DEFAULT_CONTINUE_COURSE_PROMPT; + case 'course_title': + return DEFAULT_COURSE_TITLE_PROMPT; + case 'course_summary': + return DEFAULT_COURSE_SUMMARY_PROMPT; + case 'direct_generation_lite': + return DEFAULT_DIRECT_GENERATION_LITE_PROMPT; + case 'direct_generation_lite_outline': + return DEFAULT_DIRECT_GENERATION_LITE_OUTLINE_PROMPT; + case 'direct_generation_lite_summary': + return DEFAULT_DIRECT_GENERATION_LITE_SUMMARY_PROMPT; + case 'text_parse_xiaohongshu': + return DEFAULT_TEXT_PARSE_XIAOHONGSHU_PROMPT; + case 'text_parse_xiaolin': + return DEFAULT_TEXT_PARSE_XIAOLIN_PROMPT; + case 'text_parse_douyin': + return DEFAULT_TEXT_PARSE_DOUYIN_PROMPT; + case 'continue_course_xiaohongshu': + return DEFAULT_CONTINUE_COURSE_XIAOHONGSHU_PROMPT; + case 'continue_course_xiaolin': + return DEFAULT_CONTINUE_COURSE_XIAOLIN_PROMPT; + case 'continue_course_douyin': + return DEFAULT_CONTINUE_COURSE_DOUYIN_PROMPT; + } +} + +/** + * 获取Prompt模板 + */ +export async function getPromptTemplate(type: PromptType): Promise { + try { + const key = getPromptKey(type); + const row = await prisma.appConfig.findUnique({ where: { key } }); + if (row?.value) return row.value; + } catch (e: any) { + logger.warn(`[promptConfigService] 读取 AppConfig 失败,使用默认: ${e?.message}`); + } + return getDefaultPrompt(type); +} + +/** + * 设置Prompt模板 + */ +export async function setPromptTemplate(type: PromptType, template: string): Promise { + if (!template || typeof template !== 'string' || template.trim().length === 0) { + throw new Error('模板不能为空'); + } + const key = getPromptKey(type); + await prisma.appConfig.upsert({ + where: { key }, + create: { key, value: template.trim(), updatedAt: new Date() }, + update: { value: template.trim(), updatedAt: new Date() }, + }); +} + +/** + * 重置Prompt模板为默认值 + */ +export async function resetPromptTemplate(type: PromptType): Promise { + const key = getPromptKey(type); + const defaultValue = getDefaultPrompt(type); + await prisma.appConfig.upsert({ + where: { key }, + create: { key, value: defaultValue, updatedAt: new Date() }, + update: { value: defaultValue, updatedAt: new Date() }, + }); +} + +// --- AI 调用日志(保留原有功能,重命名导出)--- + +const MAX_PROMPT_FULL = 500 * 1024; +const MAX_RESPONSE_FULL = 500 * 1024; +const LEN_PREVIEW_PROMPT = 2000; +const LEN_PREVIEW_RESPONSE = 5000; + +function truncate(s: string, max: number): string { + if (!s || s.length <= max) return s; + return s.slice(0, max) + '\n\n…(已截断)'; +} + +export interface LogBookAiCallParams { + taskId: string; + courseId?: string | null; + chunkIndex?: number | null; + prompt: string; + response?: string | null; + status: 'success' | 'failed'; + errorMessage?: string | null; + durationMs?: number | null; +} + +/** + * 写入AI调用日志。仅用于后台排查,与主流程解耦:内部 try/catch,永不向调用方抛错。 + * 调用方应 fire-and-forget:logBookAiCall(...).catch(() => {}) + */ +export async function logBookAiCall(p: LogBookAiCallParams): Promise { + try { + const promptPreview = truncate(p.prompt || '', LEN_PREVIEW_PROMPT); + const promptFull = truncate(p.prompt || '', MAX_PROMPT_FULL); + const res = p.response ?? null; + const responsePreview = res ? truncate(res, LEN_PREVIEW_RESPONSE) : null; + const responseFull = res ? truncate(res, MAX_RESPONSE_FULL) : null; + + await prisma.bookAiCallLog.create({ + data: { + taskId: p.taskId, + courseId: p.courseId ?? null, + chunkIndex: p.chunkIndex ?? null, + status: p.status, + promptPreview, + promptFull, + responsePreview, + responseFull, + errorMessage: p.errorMessage ?? null, + durationMs: p.durationMs ?? null, + }, + }); + } catch (e: any) { + logger.warn(`[promptConfigService] 写入 book_ai_call_log 失败: ${e?.message}`); + } +} + +export async function listBookAiCallLogs(opts: { + taskId?: string; + limit?: number; + offset?: number; +}): Promise<{ items: any[]; total: number }> { + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); + const offset = Math.max(opts.offset ?? 0, 0); + const where = opts.taskId ? { taskId: opts.taskId } : {}; + + // 先查询日志 + const logs = await prisma.bookAiCallLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + select: { + id: true, + taskId: true, + courseId: true, + chunkIndex: true, + status: true, + promptPreview: true, + responsePreview: true, + errorMessage: true, + durationMs: true, + createdAt: true, + }, + }); + + // 获取所有唯一的taskId + const taskIds = [...new Set(logs.map(log => log.taskId))]; + + // 批量查询任务信息 + const tasks = await prisma.courseGenerationTask.findMany({ + where: { + id: { in: taskIds }, + }, + select: { + id: true, + createdAt: true, + sourceType: true, + persona: true, + }, + }); + + // 创建任务ID到任务信息的映射 + const taskMap = new Map(tasks.map(task => [task.id, task])); + + // 合并数据 + const items = logs.map(log => { + const task = taskMap.get(log.taskId); + return { + ...log, + taskCreatedAt: task?.createdAt || null, + sourceType: task?.sourceType || null, + persona: task?.persona || null, + }; + }); + + const total = await prisma.bookAiCallLog.count({ where }); + + return { items, total }; +} + +export async function getBookAiCallLogById(id: string): Promise<{ + id: string; + taskId: string; + courseId: string | null; + chunkIndex: number | null; + status: string; + promptPreview: string; + promptFull: string; + responsePreview: string | null; + responseFull: string | null; + errorMessage: string | null; + durationMs: number | null; + createdAt: Date; + taskCreatedAt: Date | null; + sourceType: string | null; + persona: string | null; +} | null> { + const log = await prisma.bookAiCallLog.findUnique({ + where: { id }, + }); + + if (!log) return null; + + // 查询任务信息 + const task = await prisma.courseGenerationTask.findUnique({ + where: { id: log.taskId }, + select: { + createdAt: true, + sourceType: true, + persona: true, + }, + }); + + return { + ...log, + taskCreatedAt: task?.createdAt || null, + sourceType: task?.sourceType || null, + persona: task?.persona || null, + }; +} + +// --- 模型配置读写 --- + +// 支持模型配置的 PromptType +export type ModelConfigurablePromptType = + | 'text_parse_xiaohongshu' | 'text_parse_xiaolin' | 'text_parse_douyin' + | 'direct_generation_lite' | 'direct_generation_lite_outline' | 'direct_generation_lite_summary' + | 'continue_course_xiaohongshu' | 'continue_course_xiaolin' | 'continue_course_douyin'; + +/** + * 获取模型配置 Key + */ +function getModelConfigKey(type: ModelConfigurablePromptType): string { + switch (type) { + case 'text_parse_xiaohongshu': + return MODEL_CONFIG_KEYS.TEXT_PARSE_XIAOHONGSHU; + case 'text_parse_xiaolin': + return MODEL_CONFIG_KEYS.TEXT_PARSE_XIAOLIN; + case 'text_parse_douyin': + return MODEL_CONFIG_KEYS.TEXT_PARSE_DOUYIN; + case 'direct_generation_lite': + return MODEL_CONFIG_KEYS.DIRECT_GENERATION_LITE; + case 'direct_generation_lite_outline': + return MODEL_CONFIG_KEYS.DIRECT_GENERATION_LITE_OUTLINE; + case 'direct_generation_lite_summary': + return MODEL_CONFIG_KEYS.DIRECT_GENERATION_LITE_SUMMARY; + case 'continue_course_xiaohongshu': + return MODEL_CONFIG_KEYS.CONTINUE_COURSE_XIAOHONGSHU; + case 'continue_course_xiaolin': + return MODEL_CONFIG_KEYS.CONTINUE_COURSE_XIAOLIN; + case 'continue_course_douyin': + return MODEL_CONFIG_KEYS.CONTINUE_COURSE_DOUYIN; + } +} + +/** + * 获取 Prompt 对应的模型配置(从 AppConfig 读取) + * @returns 模型 ID,如 'doubao-1.5-pro-256k-250115' 或 'doubao-1-5-lite-32k-250115';若无配置返回 null + */ +export async function getModelConfig(type: ModelConfigurablePromptType): Promise { + try { + const key = getModelConfigKey(type); + const row = await prisma.appConfig.findUnique({ where: { key } }); + if (row?.value) return row.value; + } catch (e: any) { + logger.warn(`[promptConfigService] 读取模型配置失败: ${e?.message}`); + } + return null; +} + +/** + * 设置 Prompt 对应的模型配置 + */ +export async function setModelConfig(type: ModelConfigurablePromptType, modelId: string): Promise { + if (!modelId || typeof modelId !== 'string' || modelId.trim().length === 0) { + throw new Error('模型 ID 不能为空'); + } + const key = getModelConfigKey(type); + await prisma.appConfig.upsert({ + where: { key }, + create: { key, value: modelId.trim(), updatedAt: new Date() }, + update: { value: modelId.trim(), updatedAt: new Date() }, + }); +} + +/** + * 删除模型配置(恢复使用默认模型) + */ +export async function deleteModelConfig(type: ModelConfigurablePromptType): Promise { + const key = getModelConfigKey(type); + await prisma.appConfig.deleteMany({ where: { key } }); +} diff --git a/backend/src/services/smsService.ts b/backend/src/services/smsService.ts new file mode 100644 index 0000000..2e7d326 --- /dev/null +++ b/backend/src/services/smsService.ts @@ -0,0 +1,204 @@ +import Core from '@alicloud/pop-core'; +import { logger } from '../utils/logger'; + +/** + * 阿里云号码认证服务配置 + */ +interface AliyunPhoneVerifyConfig { + accessKeyId: string; + accessKeySecret: string; + signName: string; + templateCode: string; +} + +let phoneVerifyClient: Core | null = null; +let phoneVerifyConfig: AliyunPhoneVerifyConfig | null = null; + +/** + * 号码认证服务 Endpoint + */ +const PHONE_VERIFY_ENDPOINT = 'https://dypnsapi.aliyuncs.com'; + +/** + * 初始化号码认证服务 + */ +export function initSMSService(): void { + const accessKeyId = process.env.ALIYUN_ACCESS_KEY_ID; + const accessKeySecret = process.env.ALIYUN_ACCESS_KEY_SECRET; + const signName = process.env.ALIYUN_PHONE_VERIFY_SIGN_NAME; + const templateCode = process.env.ALIYUN_PHONE_VERIFY_TEMPLATE_CODE; + + // 检查是否配置了所有必需的环境变量 + if (!accessKeyId || !accessKeySecret || !signName || !templateCode) { + logger.warn('⚠️ 阿里云号码认证服务未完全配置,将使用模拟模式'); + logger.warn(' 需要配置:ALIYUN_ACCESS_KEY_ID, ALIYUN_ACCESS_KEY_SECRET, ALIYUN_PHONE_VERIFY_SIGN_NAME, ALIYUN_PHONE_VERIFY_TEMPLATE_CODE'); + return; + } + + try { + phoneVerifyConfig = { + accessKeyId, + accessKeySecret, + signName, + templateCode, + }; + + phoneVerifyClient = new Core({ + accessKeyId, + accessKeySecret, + endpoint: PHONE_VERIFY_ENDPOINT, + apiVersion: '2017-05-25', + // 号码认证服务可能需要不同的配置 + }); + + logger.info('✅ 阿里云号码认证服务初始化成功'); + logger.info(` 签名: ${signName}`); + logger.info(` 模板: ${templateCode}`); + } catch (error) { + logger.error('❌ 阿里云号码认证服务初始化失败:', error); + phoneVerifyClient = null; + } +} + +/** + * 发送验证码短信(使用号码认证服务) + * @param phone 手机号 + * @returns 不返回验证码(由阿里云管理) + */ +export async function sendVerificationCode(phone: string): Promise { + // 验证手机号格式 + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw new Error('手机号格式不正确'); + } + + // 如果没有配置服务,使用模拟模式 + if (!phoneVerifyClient || !phoneVerifyConfig) { + logger.warn(`⚠️ 模拟发送验证码到 ${phone}`); + logger.warn(' 提示:配置阿里云号码认证服务后,将发送真实短信'); + return; + } + + try { + // 调用号码认证服务发送验证码 + // 注意:参数名称使用首字母大写的驼峰命名 + const params: any = { + PhoneNumber: phone, + SignName: phoneVerifyConfig.signName, + TemplateCode: phoneVerifyConfig.templateCode, + TemplateParam: JSON.stringify({ + code: '##code##', // 占位符,由阿里云生成验证码 + min: '5', // 有效期5分钟 + }), + CodeType: 1, // 纯数字 + CodeLength: 6, // 6位验证码 + ValidTime: 300, // 5分钟有效(300秒) + Interval: 60, // 发送间隔60秒(频率限制) + ReturnVerifyCode: false, // 不返回验证码(更安全) + DuplicatePolicy: 1, // 覆盖处理(新验证码会使旧验证码失效) + }; + + const requestOption = { + method: 'POST', + }; + + logger.debug('📱 发送验证码请求参数:', JSON.stringify(params, null, 2)); + + const response: any = await phoneVerifyClient.request('SendSmsVerifyCode', params, requestOption); + + logger.debug('📱 短信发送响应:', JSON.stringify(response)); + + // 检查响应 + if (response.Code === 'OK' && response.Success) { + logger.info(`✅ 验证码已发送到 ${phone}`); + if (response.Model?.BizId) { + logger.debug(` 业务ID: ${response.Model.BizId}`); + } + } else { + logger.error('❌ 短信发送失败:', response); + const errorMsg = response.Message || response.Code || '未知错误'; + throw new Error(`短信发送失败: ${errorMsg}`); + } + } catch (error: any) { + logger.error('❌ 短信发送异常:', error); + + // 处理特定错误码 + if (error.code === 'FREQUENCY_FAIL' || error.Code === 'FREQUENCY_FAIL') { + throw new Error('发送过于频繁,请 60 秒后再试'); + } else if (error.code === 'BUSINESS_LIMIT_CONTROL' || error.Code === 'BUSINESS_LIMIT_CONTROL') { + throw new Error('今日发送次数已达上限'); + } else if (error.code === 'MOBILE_NUMBER_ILLEGAL' || error.Code === 'MOBILE_NUMBER_ILLEGAL') { + throw new Error('手机号格式错误'); + } else if (error.code === 'FUNCTION_NOT_OPENED' || error.Code === 'FUNCTION_NOT_OPENED') { + throw new Error('未开通号码认证服务,请在阿里云控制台开通'); + } + + // 提取错误信息 + const errorMsg = error.Message || error.message || error.Code || '未知错误'; + throw new Error(`短信发送失败: ${errorMsg}`); + } +} + +/** + * 验证验证码(使用号码认证服务) + * @param phone 手机号 + * @param code 验证码 + * @returns 是否验证成功 + */ +export async function verifyCode(phone: string, code: string): Promise { + // 验证手机号格式 + if (!/^1[3-9]\d{9}$/.test(phone)) { + logger.warn(`手机号格式不正确: ${phone}`); + return false; + } + + // 验证验证码格式(6位数字) + if (!/^\d{6}$/.test(code)) { + logger.warn(`验证码格式不正确: ${code}`); + return false; + } + + // 如果没有配置服务,使用模拟模式(仅用于开发测试) + if (!phoneVerifyClient || !phoneVerifyConfig) { + logger.warn('⚠️ 使用模拟验证模式(仅用于开发测试)'); + // 开发环境可以使用固定验证码测试 + if (process.env.NODE_ENV !== 'production' && code === '123456') { + logger.warn(' 开发模式:验证码 123456 通过验证'); + return true; + } + return false; + } + + try { + // 调用号码认证服务核验验证码 + const params = { + PhoneNumber: phone, + VerifyCode: code, + CaseAuthPolicy: 1, // 不区分大小写(虽然验证码是数字) + }; + + const requestOption = { + method: 'POST', + }; + + const response: any = await phoneVerifyClient.request('CheckSmsVerifyCode', params, requestOption); + + // 检查响应 + if (response.Code === 'OK' && response.Success) { + const verifyResult = response.Model?.VerifyResult; + + if (verifyResult === 'PASS') { + logger.info(`✅ 验证码验证成功: ${phone}`); + return true; + } else { + logger.warn(`❌ 验证码验证失败: ${phone}, 结果: ${verifyResult}`); + return false; + } + } else { + logger.error('❌ 验证码核验接口调用失败:', response); + return false; + } + } catch (error: any) { + logger.error('❌ 验证码核验异常:', error); + return false; + } +} diff --git a/backend/src/services/structureChunkingService.ts b/backend/src/services/structureChunkingService.ts new file mode 100644 index 0000000..1947820 --- /dev/null +++ b/backend/src/services/structureChunkingService.ts @@ -0,0 +1,598 @@ +/** + * 结构分块服务(纯规则版) + * + * 流程:预处理 → 规则匹配章节格式 → 用规则分块 + * + * 设计原则: + * 1. 预处理:移除目录、规范化换行 + * 2. 规则匹配:使用预定义的正则模式识别章节 + * 3. 规则分块:用匹配到的正则进行确定性分块 + * 4. 过滤:移除过短的分块 + */ + +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../utils/logger'; + +// ============================================================ +// 配置常量 +// ============================================================ + +/** 最小分块内容长度,低于此值的分块会被过滤 */ +const MIN_CHUNK_CONTENT_LENGTH = 100; + +/** 最大标题长度,超过会截断 */ +const MAX_TITLE_LENGTH = 50; + +/** 目录关键字搜索范围(前多少行) */ +const TOC_KEYWORD_SEARCH_RANGE = 500; + +/** 目录结束扫描范围(从目录开始向后扫描多少行) */ +const TOC_CONTENT_SCAN_RANGE = 1000; + +/** 快速匹配最少需要的章节数 */ +const MIN_QUICK_MATCH_COUNT = 2; + +/** + * 快速匹配规则(严格版,宁可错过不要误判) + * + * 设计原则: + * - 只匹配最常见、最确定的格式 + * - 正则严格,避免误匹配 + * - 按优先级排序 + */ +const QUICK_PATTERNS = [ + // 第X章(最常见)- 要求章后有空格,避免匹配 "第7章介绍..." 这种前言句子 + { name: '第X章', regex: /^\s*第\s*[一二三四五六七八九十百零\d]+\s*章\s+/m }, + // Chapter X(英文书籍) + { name: 'Chapter', regex: /^\s*Chapter\s+\d+[\s.::]/im }, + // 第X节/课/讲 + { name: '第X节', regex: /^\s*第\s*[一二三四五六七八九十百零\d]+\s*[节课讲]\s+/m }, + // 一、二、三、(中文序号,要求后面有内容) + { name: '中文序号', regex: /^\s*[一二三四五六七八九十]+[、..]\s*\S/m }, +]; + +// ============================================================ +// 接口定义 +// ============================================================ + +export interface Chunk { + id: string; + title: string; + content: string; + order: number; +} + +export interface ChunkingResult { + success: boolean; + pattern: string | null; + chunks: Chunk[]; + totalCharacters: number; + /** 失败原因(仅当 success=false 时有值) */ + failureReason?: string; +} + +// ============================================================ +// 服务实现 +// ============================================================ + +export class StructureChunkingService { + /** + * 预处理:移除序言区域(版权信息、内容简介等) + */ + private removePrefaceContent(text: string): string { + const lines = text.split('\n'); + let contentStart = 0; + + // 常见序言关键词 + const prefaceKeywords = [ + '版权信息', '内容简介', '前言', 'PREFACE', '序言', '出版说明', + '作者简介', '编者按', '导读', '引言', '写在前面' + ]; + + // 找到序言结束位置 + for (let i = 0; i < Math.min(lines.length, 200); i++) { + const line = lines[i].trim(); + + // 检测是否是章节开头(正文开始) + if (/^第\s*[一二三四五六七八九十\d]+\s*[章节课]/.test(line) || + /^Chapter\s+\d+/i.test(line) || + /^[一二三四五六七八九十]+[、.]/.test(line)) { + contentStart = i; + break; + } + + // 检测序言区域 + for (const keyword of prefaceKeywords) { + if (line.includes(keyword)) { + // 继续往下找,直到找到正文 + contentStart = i + 1; + } + } + } + + if (contentStart > 0) { + logger.info(`[StructureChunking] 跳过序言区域: 前 ${contentStart} 行`); + return lines.slice(contentStart).join('\n'); + } + + return text; + } + + /** + * 快速规则匹配(严格版) + * + * 返回匹配到的模式和章节数,如果没有匹配返回 null + */ + private tryQuickMatch(text: string): { name: string; regex: RegExp; count: number } | null { + // 分块用的正则映射(必须行首匹配,章/节后必须有空格) + const chunkRegexMap: Record = { + '第X章': /^\s*第\s*[一二三四五六七八九十百零\d]+\s*章\s+[^\n]{0,50}/m, + 'Chapter': /^\s*Chapter\s+\d+[^\n]{0,50}/im, + '第X节': /^\s*第\s*[一二三四五六七八九十百零\d]+\s*[节课讲]\s+[^\n]{0,50}/m, + '中文序号': /^\s*[一二三四五六七八九十]+[、..][^\n]{0,50}/m, + }; + + for (const pattern of QUICK_PATTERNS) { + // 使用全局匹配计数 + const globalRegex = new RegExp(pattern.regex.source, 'gm' + (pattern.regex.flags.includes('i') ? 'i' : '')); + const matches = text.match(globalRegex); + const count = matches?.length || 0; + + if (count >= MIN_QUICK_MATCH_COUNT) { + logger.info(`[StructureChunking] 快速匹配成功: ${pattern.name}, 匹配 ${count} 个章节`); + // 返回用于分块的正则 + const chunkRegex = chunkRegexMap[pattern.name] || pattern.regex; + return { name: pattern.name, regex: chunkRegex, count }; + } + } + + logger.info(`[StructureChunking] 规则匹配未命中`); + return null; + } + + /** + * 检测分块质量,判断是否失败 + * + * 失败条件: + * 1. 内容丢失 > 30%(分块总字数 < 原文 70%) + * 2. 分块集中在末尾(最后一个块占总内容 50% 以上) + * + * @returns 失败原因,如果没有失败则返回 null + */ + private detectChunkingFailure(chunks: Chunk[], originalLength: number): string | null { + if (chunks.length === 0) { + return '无有效分块'; + } + + // 计算分块总字数 + const totalChunkChars = chunks.reduce((sum, chunk) => sum + chunk.content.length, 0); + + // 检测1:内容丢失 > 30% + const contentRatio = totalChunkChars / originalLength; + if (contentRatio < 0.7) { + const lossPercent = Math.round((1 - contentRatio) * 100); + logger.info(`[StructureChunking] 内容丢失检测: 分块总字数=${totalChunkChars}, 原文=${originalLength}, 比例=${(contentRatio * 100).toFixed(1)}%`); + return `内容丢失过多 (${lossPercent}%)`; + } + + // 检测2:最后一个块占比过高(分块集中在末尾) + const lastChunk = chunks[chunks.length - 1]; + const lastChunkRatio = lastChunk.content.length / totalChunkChars; + if (lastChunkRatio > 0.5 && chunks.length > 2) { + const lastChunkPercent = Math.round(lastChunkRatio * 100); + logger.info(`[StructureChunking] 末尾集中检测: 最后块=${lastChunk.content.length}字, 总分块=${totalChunkChars}字, 占比=${lastChunkPercent}%`); + return `分块集中在末尾 (最后块占${lastChunkPercent}%)`; + } + + return null; + } + + /** + * 解析文档并按章分块 + * + * 流程:预处理 → 规则匹配 → 规则分块 + */ + async parseAsync(text: string): Promise { + const startTime = Date.now(); + + // 1. 预处理:规范化换行 + let processed = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + // 2. 移除目录 + processed = this.removeTableOfContents(processed); + + // 3. 移除序言(版权信息、内容简介等) + processed = this.removePrefaceContent(processed); + + // 4. 清理多余空行 + processed = processed.replace(/\n{3,}/g, '\n\n').trim(); + + // 5. 规则匹配 + const quickMatch = this.tryQuickMatch(processed); + if (!quickMatch) { + logger.info(`[StructureChunking] 未匹配到章节格式`); + return { + success: false, + pattern: null, + chunks: [], + totalCharacters: processed.length, + }; + } + + const regex = quickMatch.regex; + const patternName = quickMatch.name; + + // 6. 用正则分块 + let chunks = this.splitByChapter(processed, regex); + + // 7. 过滤无效分块 + chunks = this.filterChunks(chunks); + + // 8. 如果过滤后分块太少,视为失败 + if (chunks.length < 2) { + logger.warn(`[StructureChunking] 有效分块不足 (${chunks.length}),放弃结构分块`); + return { + success: false, + pattern: null, + chunks: [], + totalCharacters: processed.length, + }; + } + + // 9. 失败检测:检查分块质量 + const failureReason = this.detectChunkingFailure(chunks, processed.length); + if (failureReason) { + logger.warn(`[StructureChunking] 分块质量检测失败: ${failureReason}`); + return { + success: false, + pattern: null, + chunks: [], + totalCharacters: processed.length, + failureReason, + }; + } + + const duration = Date.now() - startTime; + logger.info(`[StructureChunking] 完成: pattern=${patternName}, chunks=${chunks.length}, 耗时=${duration}ms`); + + return { + success: true, + pattern: patternName, + chunks, + totalCharacters: processed.length, + }; + } + + /** + * 预处理:移除目录区域 + * + * 识别策略: + * 1. 查找 "目录" 关键字 + * 2. 向下扫描,识别目录特征行(页码、省略号、密集短行) + * 3. 找到第一个真正的正文章节开始位置 + */ + private removeTableOfContents(text: string): string { + const lines = text.split('\n'); + let tocStart = -1; + let tocEnd = -1; + + // 1. 查找 "目录" 关键字 + for (let i = 0; i < Math.min(lines.length, TOC_KEYWORD_SEARCH_RANGE); i++) { + const line = lines[i].trim(); + if (/^目\s*录$|^CONTENTS?$/i.test(line)) { + tocStart = i; + break; + } + } + + if (tocStart === -1) { + return text; + } + + // 2. 目录特征模式 + const isTocLine = (line: string): boolean => { + const trimmed = line.trim(); + if (!trimmed) return true; // 空行在目录区域内算目录 + if (trimmed.length < 3) return true; // 太短的行 + + // PDF 目录页脚(如 "目录VII"、"目录X") + if (/^目录[IVXLCDM\d]+$/i.test(trimmed)) return true; + + // 典型目录格式 + if (/\.{3,}/.test(trimmed)) return true; // 省略号 ... + if (/…{2,}/.test(trimmed)) return true; // 中文省略号 + if (/\d{1,4}\s*$/.test(trimmed)) return true; // 行末页码 + if (/^\d+\.\d+/.test(trimmed)) return true; // 小节编号 + if (/^第.*[部章节课].*\d+$/.test(trimmed)) return true; // 目录中的章节条目(如 "第1章 绪论2") + if (/^附录[A-Z]/.test(trimmed)) return true; // 附录条目 + + // 短行 + 无标点(目录常见格式) + if (trimmed.length < 25 && !/[,。!?、;:]/.test(trimmed)) return true; + + return false; + }; + + // 3. 正文开始特征(章节标题 + 后面是正常段落) + const isContentStart = (lineIdx: number): boolean => { + const line = lines[lineIdx]?.trim() || ''; + + // 必须是章节开头(但不是目录中的章节条目) + const isChapter = /^第\s*[一二三四五六七八九十\d]+\s*[章部分课节讲篇]/.test(line) || + /^[一二三四五六七八九十]+[、..]/.test(line) || + /^Chapter\s+\d+/i.test(line); + + if (!isChapter) return false; + + // 本行不能像目录行(有省略号、页码、末尾数字) + if (/\.{3,}|…{2,}|\d{1,4}\s*$/.test(line)) return false; + + // 检查后续行是否有真正的长段落内容(>50字符) + // 目录中的章节条目后面通常只有短行(小节编号等) + // 正文章节后面会有实际的段落内容 + let longLineCount = 0; + for (let j = lineIdx + 1; j < Math.min(lineIdx + 15, lines.length); j++) { + const nextLine = lines[j].trim(); + // 长行:超过 50 字符,且不像目录/小节编号 + if (nextLine.length > 50 && !/^\d+\.\d+/.test(nextLine)) { + longLineCount++; + } + } + + // 需要至少 2 行长内容才认为是正文开始 + return longLineCount >= 2; + }; + + // 4. 向下扫描,找目录结束位置 + let consecutiveNonToc = 0; + + for (let i = tocStart + 1; i < Math.min(lines.length, tocStart + TOC_CONTENT_SCAN_RANGE); i++) { + const line = lines[i].trim(); + + // 空行不重置计数 + if (!line) continue; + + // 检测是否是正文开始 + if (isContentStart(i)) { + tocEnd = i - 1; + break; + } + + // 连续非目录行计数 + if (!isTocLine(line)) { + consecutiveNonToc++; + // 连续 5 行非目录格式,认为目录结束 + if (consecutiveNonToc >= 5) { + // 回溯找到第一个非空行 + for (let k = i - consecutiveNonToc; k <= i; k++) { + if (lines[k].trim()) { + tocEnd = k - 1; + break; + } + } + break; + } + } else { + consecutiveNonToc = 0; + } + } + + // 5. 移除目录区域 + if (tocEnd > tocStart) { + const before = lines.slice(0, tocStart); + const after = lines.slice(tocEnd + 1); + logger.info(`[StructureChunking] 移除目录: 行 ${tocStart + 1}-${tocEnd + 1}`); + return [...before, ...after].join('\n'); + } + + return text; + } + + /** + * 截断标题到合理长度 + * + * 策略: + * 1. 先提取章节标识符 + * 2. 清理标题部分(移除页码、省略号等) + * 3. 硬截断兜底 + */ + private truncateTitle(title: string): string { + let result = title.trim(); + + // 截断到第一个换行 + const newlineIdx = result.indexOf('\n'); + if (newlineIdx > 0) { + result = result.substring(0, newlineIdx); + } + + // 尝试提取章节标识符和标题 + const chapterPatterns = [ + /^(第\s*[一二三四五六七八九十百零\d]+\s*[章部分课节讲篇])\s*(.*)$/, + /^(Chapter\s+\d+)\s*(.*)$/i, + /^([一二三四五六七八九十]+[、..])\s*(.*)$/, + ]; + + for (const pattern of chapterPatterns) { + const match = result.match(pattern); + if (match) { + const prefix = match[1].trim(); + let titlePart = match[2].trim(); + + // 清理标题部分:移除目录特征 + titlePart = titlePart + .replace(/\.{3,}.*$/, '') // 省略号及其后 + .replace(/…{2,}.*$/, '') // 中文省略号 + .replace(/\s+\d{1,4}\s*$/, '') // 行末页码 + .replace(/\s+\d+\.\d+.*$/, '') // 小节编号 + .replace(/\d+\.\d+\S*$/, '') // 行末小节编号 + .trim(); + + // 移除标题中间的页码(但要小心不要误删) + // 只移除 "标题 123 小节" 这种格式中的页码 + titlePart = titlePart.replace(/^(.{2,20})\s+\d{1,4}\s+\d+\.\d+.*$/, '$1'); + titlePart = titlePart.replace(/^(.{2,20})\s+\d{1,4}\s*$/, '$1'); + + // 在标题部分找第一个合理的断点 + const breakpoints = [',', '。', ';', ':', ',', ';', ':']; + for (const bp of breakpoints) { + const idx = titlePart.indexOf(bp); + if (idx > 0 && idx < 30) { + titlePart = titlePart.substring(0, idx); + break; + } + } + + // 限制标题部分长度 + if (titlePart.length > 25) { + const spaceIdx = titlePart.indexOf(' ', 8); + if (spaceIdx > 0 && spaceIdx < 25) { + titlePart = titlePart.substring(0, spaceIdx); + } else { + titlePart = titlePart.substring(0, 25); + } + } + + result = titlePart ? `${prefix} ${titlePart}`.trim() : prefix; + break; + } + } + + // 硬截断兜底 + if (result.length > MAX_TITLE_LENGTH) { + result = result.substring(0, MAX_TITLE_LENGTH) + '...'; + } + + return result.trim(); + } + + /** + * 按章分块 + */ + private splitByChapter(text: string, pattern: RegExp): Chunk[] { + const chunks: Chunk[] = []; + const flags = pattern.flags.includes('i') ? 'gmi' : 'gm'; + const regex = new RegExp(`(${pattern.source})`, flags); + const parts = text.split(regex).filter(p => p?.trim()); + + let currentTitle = ''; + let order = 0; + + for (const part of parts) { + const trimmedPart = part.trim(); + + // 判断是否是标题 + const testRegex = new RegExp(pattern.source, pattern.flags); + if (testRegex.test(trimmedPart)) { + currentTitle = this.truncateTitle(trimmedPart); + } else if (currentTitle && trimmedPart) { + chunks.push({ + id: uuidv4(), + title: currentTitle, + content: trimmedPart, + order: order++, + }); + currentTitle = ''; + } + } + + return chunks; + } + + /** + * 从标题中提取章节号 + * 返回数字,无法提取返回 -1 + */ + private extractChapterNumber(title: string): number { + // 中文数字映射 + const chineseNums: Record = { + '零': 0, '一': 1, '二': 2, '三': 3, '四': 4, + '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, + '十': 10, '百': 100, + }; + + // 匹配 "第X章" / "第X课" 等 + const match = title.match(/第\s*([零一二三四五六七八九十百\d]+)\s*[章课节部分篇讲]/); + if (!match) return -1; + + const numStr = match[1]; + + // 纯数字 + if (/^\d+$/.test(numStr)) { + return parseInt(numStr, 10); + } + + // 中文数字转换(简化版,处理常见情况) + let result = 0; + for (let i = 0; i < numStr.length; i++) { + const char = numStr[i]; + const val = chineseNums[char]; + if (val === undefined) continue; + + if (char === '十') { + result = result === 0 ? 10 : result * 10; + } else if (char === '百') { + result = result * 100; + } else { + result = result * 10 + val; + } + } + + // 特殊处理:十一=11, 二十=20 + if (numStr === '十') return 10; + if (numStr.startsWith('十')) { + const rest = numStr.slice(1); + return 10 + (chineseNums[rest] || 0); + } + if (numStr.endsWith('十')) { + const first = numStr.slice(0, -1); + return (chineseNums[first] || 1) * 10; + } + + return result || -1; + } + + /** + * 过滤和清理分块 + * + * 策略: + * 1. 过滤内容过短的分块 + * 2. 检测章节顺序异常(如 6→7→...→1→2),移除目录残留 + */ + private filterChunks(chunks: Chunk[]): Chunk[] { + // 1. 先过滤过短的分块 + let filtered = chunks.filter(chunk => { + if (chunk.content.length < MIN_CHUNK_CONTENT_LENGTH) { + logger.warn(`[StructureChunking] 过滤短分块: "${chunk.title}" (${chunk.content.length} 字符)`); + return false; + } + return true; + }); + + // 2. 检测章节顺序异常:找"第1章"的位置 + // 如果第1章不在开头,说明前面可能是目录残留 + const chapterNumbers = filtered.map(c => this.extractChapterNumber(c.title)); + const firstChapterIdx = chapterNumbers.findIndex(n => n === 1); + + if (firstChapterIdx > 0) { + // 检查是否是顺序异常(前面的章节号都比后面大) + const beforeFirst = chapterNumbers.slice(0, firstChapterIdx); + const hasLargerBefore = beforeFirst.some(n => n > 1); + + if (hasLargerBefore) { + // 移除第1章之前的所有分块(可能是目录残留) + const removed = filtered.slice(0, firstChapterIdx); + logger.warn(`[StructureChunking] 检测到章节顺序异常,移除目录残留: ${removed.map(c => c.title).join(', ')}`); + filtered = filtered.slice(firstChapterIdx); + } + } + + // 3. 重新编号 + return filtered.map((chunk, idx) => ({ + ...chunk, + order: idx, + })); + } + +} + +// 导出单例 +export const structureChunkingService = new StructureChunkingService(); diff --git a/backend/src/services/taskService.ts b/backend/src/services/taskService.ts new file mode 100644 index 0000000..91d6f1f --- /dev/null +++ b/backend/src/services/taskService.ts @@ -0,0 +1,221 @@ +/** + * 任务管理服务 + */ + +import { PrismaClient } from '@prisma/client'; +import { TaskStatus } from '../types/ai'; +import { logger } from '../utils/logger'; +import prisma from '../utils/prisma'; +import { generateThemeColor } from '../utils/courseTheme'; + +export class TaskService { + constructor(private db: PrismaClient = prisma) {} + + /** + * 创建任务和课程 + * @param userId 用户ID + * @param sourceText 原始文本 + * @param sourceType 来源类型(可选):direct | document | continue + * @param persona Prompt类型(可选):direct_test_lite | text_parse_xiaohongshu 等 + * @param options 可选:saveAsDraft(后台创建不自动发布)、visibility(public | private) + */ + async createTask( + userId: string, + sourceText: string, + sourceType?: string, + persona?: string, + options?: { saveAsDraft?: boolean; visibility?: 'public' | 'private'; parentCourseId?: string } + ): Promise<{ taskId: string; courseId: string }> { + try { + const saveAsDraft = options?.saveAsDraft === true; + const visibility = options?.visibility === 'public' ? 'public' : 'private'; + const parentCourseId = options?.parentCourseId || null; + + // 水印:App 用户 AI 创建(不传 saveAsDraft)→ rectangle.portrait.fill;管理后台创建公开/私有课程(传 saveAsDraft: true)→ doc.text.fill + const watermarkIcon = saveAsDraft ? 'doc.text.fill' : 'rectangle.portrait.fill'; + + // 1. 创建课程(临时标题,后续会更新) + // 后台 saveAsDraft 时写 createdAsDraft: true,完成逻辑只读 Course 即可判断,不额外查 Task(App 路径零额外查询) + const course = await this.db.course.create({ + data: { + title: '课程创建中', + description: '课程正在生成中,请稍候...', + type: 'system', + status: 'draft', + isPortrait: true, + createdBy: userId, + visibility, + createdAsDraft: saveAsDraft, + totalNodes: 0, + watermarkIcon, + parentCourseId, + }, + }); + + // ✅ 使用真实 courseId 生成主题色(确保一致性) + const themeColor = generateThemeColor(course.id); + await this.db.course.update({ + where: { id: course.id }, + data: { themeColor }, + }); + + // ✅ 仅 App 创建时立即创建 UserCourse,使课程出现在用户课程列表中;后台 saveAsDraft 时不创建 + if (!saveAsDraft) { + await this.db.userCourse.create({ + data: { + userId, + courseId: course.id, + }, + }); + } + + // 2. 创建生成任务(包含 sourceType、persona、saveAsDraft) + const task = await this.db.courseGenerationTask.create({ + data: { + courseId: course.id, + userId, + sourceText, + sourceType: sourceType || null, + persona: persona || null, + status: 'pending', + progress: 0, + modelProvider: 'doubao', + saveAsDraft, + }, + }); + + logger.info( + `[TaskService] 创建任务成功: taskId=${task.id}, courseId=${course.id}, sourceType=${sourceType || 'null'}, persona=${persona || 'null'}, saveAsDraft=${saveAsDraft}` + ); + + return { + taskId: task.id, + courseId: course.id, + }; + } catch (error: any) { + logger.error(`[TaskService] 创建任务失败: ${error.message}`, error); + throw new Error(`创建任务失败: ${error.message}`); + } + } + + /** + * 获取任务状态 + */ + async getTaskStatus(taskId: string) { + const task = await this.db.courseGenerationTask.findUnique({ + where: { id: taskId }, + include: { + course: true, + }, + }); + + if (!task) { + throw new Error('任务不存在'); + } + + return { + taskId: task.id, + courseId: task.courseId, + status: task.status as TaskStatus, + progress: task.progress, + currentStep: task.currentStep || undefined, + errorMessage: task.errorMessage || undefined, + createdAt: task.createdAt.toISOString(), + courseTitle: task.course?.title ?? undefined, + }; + } + + /** + * 更新任务状态 + */ + async updateStatus( + taskId: string, + status: TaskStatus, + progress?: number, + currentStep?: string, + errorMessage?: string + ): Promise { + try { + await this.db.courseGenerationTask.update({ + where: { id: taskId }, + data: { + status, + progress: progress !== undefined ? progress : undefined, + currentStep: currentStep !== undefined ? currentStep : undefined, + errorMessage: errorMessage !== undefined ? errorMessage : undefined, + }, + }); + + logger.info( + `[TaskService] 更新任务状态: taskId=${taskId}, status=${status}, progress=${progress}` + ); + } catch (error: any) { + logger.error(`[TaskService] 更新任务状态失败: ${error.message}`, error); + throw new Error(`更新任务状态失败: ${error.message}`); + } + } + + /** + * 保存本次任务实际使用的模型 ID(供调用记录详情展示) + */ + async saveModelId(taskId: string, modelId: string): Promise { + try { + await this.db.courseGenerationTask.update({ + where: { id: taskId }, + data: { modelId }, + }); + } catch (error: any) { + logger.warn(`[TaskService] 保存 modelId 失败: taskId=${taskId}`, error.message); + } + } + + /** + * 保存当时发给模型的真实提示词(供调用记录查看,仅 fire-and-forget 调用,不阻塞主流程) + */ + async savePromptSent(taskId: string, prompt: string): Promise { + try { + await this.db.courseGenerationTask.update({ + where: { id: taskId }, + data: { promptSent: prompt }, + }); + } catch (error: any) { + logger.warn(`[TaskService] 保存 promptSent 失败: taskId=${taskId}`, error.message); + } + } + + /** + * 保存大纲 + */ + async saveOutline(taskId: string, outline: any): Promise { + try { + await this.db.courseGenerationTask.update({ + where: { id: taskId }, + data: { + outline: outline as any, + }, + }); + + logger.info(`[TaskService] 大纲保存成功: taskId=${taskId}`); + } catch (error: any) { + logger.error(`[TaskService] 保存大纲失败: ${error.message}`, error); + throw new Error(`保存大纲失败: ${error.message}`); + } + } + + /** + * 获取任务详情(包含大纲) + */ + async getTask(taskId: string) { + const task = await this.db.courseGenerationTask.findUnique({ + where: { id: taskId }, + }); + + if (!task) { + throw new Error('任务不存在'); + } + + return task; + } +} + +export const taskService = new TaskService(); diff --git a/backend/src/services/titleGenerationService.ts b/backend/src/services/titleGenerationService.ts new file mode 100644 index 0000000..3306db4 --- /dev/null +++ b/backend/src/services/titleGenerationService.ts @@ -0,0 +1,57 @@ +/** + * 课程标题生成服务 + * 并行于主流程,用前 2000 字调用 AI 生成标题并更新 Course.title;失败仅打日志,不阻塞主流程。 + */ + +import prisma from '../utils/prisma'; +import { logger } from '../utils/logger'; +import { getPromptTemplate } from './promptConfigService'; +import { ModelProviderFactory } from './modelProvider'; + +const TITLE_EXCERPT_LENGTH = 2000; + +/** + * 生成课程标题并更新数据库 + * 输入取 sourceText 前 2000 字;Prompt 从配置读取(course_title_prompt);输出 JSON key 为 course_title。 + * modelId 可选:不传用默认模型;传则用指定模型(如豆包 lite,供「直接测试-豆包lite」对比)。 + */ +export async function generateAndUpdateCourseTitle( + courseId: string, + sourceText: string, + modelId?: string +): Promise { + try { + const excerpt = (sourceText ?? '').slice(0, TITLE_EXCERPT_LENGTH); + const systemPrompt = await getPromptTemplate('course_title'); + const adapter = ModelProviderFactory.create('doubao'); + console.log('systemPrompt', systemPrompt); + console.log('excerpt', excerpt); + console.log('modelId', modelId); + const raw = await adapter.generateShortCompletion(systemPrompt, excerpt, modelId); + console.log('raw', raw); + console.log('--------------------------------'); + + let jsonStr = raw.trim(); + if (jsonStr.startsWith('```')) { + jsonStr = jsonStr.replace(/^```json?\n?/, '').replace(/\n?```$/, '').trim(); + } + const firstBrace = jsonStr.indexOf('{'); + if (firstBrace >= 0) jsonStr = jsonStr.slice(firstBrace); + + const parsed = JSON.parse(jsonStr) as { course_title?: string }; + const title = typeof parsed.course_title === 'string' ? parsed.course_title.trim() : null; + if (!title || title.length === 0) { + logger.warn(`[TitleGenerationService] 解析结果无有效 course_title: courseId=${courseId}`); + return; + } + + const finalTitle = title.slice(0, 200); + await prisma.course.update({ + where: { id: courseId }, + data: { title: finalTitle }, + }); + logger.info(`[TitleGenerationService] 已更新课程标题: courseId=${courseId}, title=${finalTitle}`); + } catch (error: any) { + logger.error(`[TitleGenerationService] 标题生成失败: courseId=${courseId}, error=${error.message}`, error); + } +} diff --git a/backend/src/services/xhsCoverTemplatesService.ts b/backend/src/services/xhsCoverTemplatesService.ts new file mode 100644 index 0000000..d5a1a06 --- /dev/null +++ b/backend/src/services/xhsCoverTemplatesService.ts @@ -0,0 +1,806 @@ +/** + * 小红书爆款封面模板服务 + * 多套模板,按模板 ID 生成 PNG Buffer,供 Playground 页面与 API 使用 + */ + +import { createCanvas, CanvasRenderingContext2D } from 'canvas'; + +const WIDTH = 540; +const HEIGHT = 720; +const FONT_FALLBACK = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif'; + +/** 模板 ID 与中文名(用于展示页) */ +export const XHS_COVER_TEMPLATES: { id: string; name: string }[] = [ + { 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: '可爱卡通' }, +]; + +export type XhsCoverOptions = { + lines?: string[]; +}; + +const DEFAULT_LINES = ['谁懂!', '人生最好的事情', '不过是:', '拥有好朋友和', '幸福生活!']; + +function wrapText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, + maxLines = 6 +): string[] { + const lines: string[] = []; + let currentLine = ''; + for (const char of text.split('')) { + const testLine = currentLine + char; + const metrics = ctx.measureText(testLine); + if (metrics.width > maxWidth && currentLine.length > 0) { + lines.push(currentLine); + currentLine = char; + if (lines.length >= maxLines) break; + } else { + currentLine = testLine; + } + } + if (currentLine.length > 0) lines.push(currentLine); + return lines; +} + +function drawRoundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number +) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +function drawCardText( + ctx: CanvasRenderingContext2D, + lines: string[], + cardX: number, + cardY: number, + cardW: number, + cardH: number, + opts: { fontSize?: number; lineHeight?: number; color?: string } = {} +) { + const fontSize = opts.fontSize ?? 26; + const lineHeight = opts.lineHeight ?? 38; + const color = opts.color ?? '#000000'; + const textX = cardX + 32; + let y = cardY + 44; + const maxTextWidth = cardW - 64; + ctx.font = `bold ${fontSize}px ${FONT_FALLBACK}`; + ctx.fillStyle = color; + ctx.textAlign = 'left'; + for (const line of lines) { + const wrapped = wrapText(ctx, line, maxTextWidth, 1); + for (const seg of wrapped) { + ctx.fillText(seg, textX, y); + y += lineHeight; + } + } +} + +/** 1. 网格卡片 */ +function renderGridCard(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFFDE7'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const gridStep = 24; + ctx.strokeStyle = 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 0.5; + for (let x = 0; x <= WIDTH; x += gridStep) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, HEIGHT); + ctx.stroke(); + } + for (let y = 0; y <= HEIGHT; y += gridStep) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(WIDTH, y); + ctx.stroke(); + } + const padding = 48; + const cardX = padding; + const cardY = 120; + const cardW = WIDTH - padding * 2; + const cardH = 380; + ctx.fillStyle = '#FFFFFF'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.04)'; + ctx.lineWidth = 0.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.stroke(); + const tabW = 60; + const tabH = 12; + ctx.fillStyle = '#FFD93D'; + drawRoundRect(ctx, cardX + (cardW - tabW) / 2, cardY - tabH / 2, tabW, tabH, 4); + ctx.fill(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH); + const dashY = cardY + cardH - 40; + ctx.strokeStyle = '#FFD93D'; + ctx.lineWidth = 2; + ctx.setLineDash([6, 6]); + for (let i = 0; i < 4; i++) { + ctx.beginPath(); + ctx.moveTo(cardX + 32 + i * 36, dashY); + ctx.lineTo(cardX + 32 + i * 36 + 24, dashY); + ctx.stroke(); + } + ctx.setLineDash([]); + const stickerX = cardX + cardW - 72; + const stickerY = cardY + cardH - 72; + ctx.fillStyle = '#FFF3CD'; + ctx.beginPath(); + ctx.arc(stickerX, stickerY, 32, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.08)'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.font = `20px ${FONT_FALLBACK}`; + ctx.fillStyle = '#333'; + ctx.textAlign = 'center'; + ctx.fillText('贴纸', stickerX, stickerY + 6); +} + +/** 2. 极简白底 */ +function renderMinimalWhite(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const padding = 56; + const cardX = padding; + const cardY = 140; + const cardW = WIDTH - padding * 2; + const cardH = HEIGHT - 280; + ctx.strokeStyle = 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 0.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 16); + ctx.stroke(); + ctx.font = `bold 28px ${FONT_FALLBACK}`; + ctx.fillStyle = '#000000'; + ctx.textAlign = 'left'; + let y = cardY + 52; + const maxW = cardW - 64; + for (const line of lines) { + const wrapped = wrapText(ctx, line, maxW, 1); + for (const seg of wrapped) { + ctx.fillText(seg, cardX + 32, y); + y += 42; + } + } +} + +/** 3. 渐变柔粉 */ +function renderGradientSoft(ctx: CanvasRenderingContext2D, lines: string[]) { + const g = ctx.createLinearGradient(0, 0, WIDTH, HEIGHT); + g.addColorStop(0, '#FFE4EC'); + g.addColorStop(0.5, '#FFF0F5'); + g.addColorStop(1, '#FFE4EC'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 40; + const cardY = 100; + const cardW = WIDTH - 80; + const cardH = 400; + ctx.fillStyle = 'rgba(255,255,255,0.92)'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 24); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,182,193,0.4)'; + ctx.lineWidth = 1; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 24); + ctx.stroke(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { fontSize: 26 }); +} + +/** 4. 苹果备忘录 */ +function renderMemoNote(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFFDE7'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const lineStep = 32; + ctx.strokeStyle = 'rgba(0,0,0,0.08)'; + ctx.lineWidth = 0.5; + for (let y = 80; y < HEIGHT; y += lineStep) { + ctx.beginPath(); + ctx.moveTo(40, y); + ctx.lineTo(WIDTH - 40, y); + ctx.stroke(); + } + ctx.font = `bold 26px ${FONT_FALLBACK}`; + ctx.fillStyle = '#1a1a1a'; + ctx.textAlign = 'left'; + let y = 100; + for (const line of lines) { + const wrapped = wrapText(ctx, line, WIDTH - 100, 1); + for (const seg of wrapped) { + ctx.fillText(seg, 48, y); + y += 36; + } + } + ctx.fillStyle = 'rgba(255,200,0,0.25)'; + ctx.fillRect(0, 0, 8, HEIGHT); +} + +/** 5. 深色暗黑 */ +function renderDarkMode(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 44; + const cardY = 100; + const cardW = WIDTH - 88; + const cardH = 400; + ctx.fillStyle = '#2d2d2d'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.lineWidth = 1; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.stroke(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { color: '#f0f0f0', fontSize: 26 }); +} + +/** 6. 奶油胶可爱 */ +function renderPastelCute(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFF5F7'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 36; + const cardY = 90; + const cardW = WIDTH - 72; + const cardH = 420; + ctx.fillStyle = '#FFFFFF'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 28); + ctx.fill(); + ctx.strokeStyle = '#FFB6C1'; + ctx.lineWidth = 2; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 28); + ctx.stroke(); + ctx.fillStyle = '#FFB6C1'; + drawRoundRect(ctx, cardX + (cardW - 80) / 2, cardY - 10, 80, 20, 10); + ctx.fill(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { fontSize: 25 }); + const stickerX = cardX + cardW - 70; + const stickerY = cardY + cardH - 70; + ctx.fillStyle = '#FFE4E1'; + ctx.beginPath(); + ctx.arc(stickerX, stickerY, 36, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#FFB6C1'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.font = `22px ${FONT_FALLBACK}`; + ctx.fillStyle = '#C71585'; + ctx.textAlign = 'center'; + ctx.fillText('✨', stickerX, stickerY + 8); +} + +/** 7. 杂志分栏 */ +function renderMagazine(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FAFAFA'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, WIDTH, 6); + ctx.fillRect(0, HEIGHT - 6, WIDTH, 6); + const leftCol = 48; + const rightCol = WIDTH / 2 + 24; + const maxW = WIDTH / 2 - 56; + ctx.font = `bold 30px ${FONT_FALLBACK}`; + ctx.fillStyle = '#000'; + ctx.textAlign = 'left'; + let y = 120; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const x = i % 2 === 0 ? leftCol : rightCol; + if (i % 2 === 0 && i > 0) y += 44; + const wrapped = wrapText(ctx, line, maxW, 1); + for (const seg of wrapped) { + ctx.fillText(seg, x, y); + y += 40; + } + } + ctx.fillStyle = 'rgba(0,0,0,0.06)'; + ctx.fillRect(WIDTH / 2 - 1, 60, 2, HEIGHT - 120); +} + +/** 8. 复古学习 */ +function renderRetroStudy(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#F5E6D3'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 44; + const cardY = 100; + const cardW = WIDTH - 88; + const cardH = 400; + ctx.fillStyle = '#FFFBF5'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 12); + ctx.fill(); + ctx.strokeStyle = '#C4A77D'; + ctx.lineWidth = 1; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 12); + ctx.stroke(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { color: '#3d2914', fontSize: 25 }); + ctx.strokeStyle = '#C4A77D'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + for (let i = 0; i < 3; i++) { + ctx.beginPath(); + ctx.moveTo(cardX + 32 + i * 40, cardY + cardH - 36); + ctx.lineTo(cardX + 32 + i * 40 + 28, cardY + cardH - 36); + ctx.stroke(); + } + ctx.setLineDash([]); +} + +/** 9. 青提甜瓜 */ +function renderFreshMelon(ctx: CanvasRenderingContext2D, lines: string[]) { + const g = ctx.createLinearGradient(0, 0, 0, HEIGHT); + g.addColorStop(0, '#E8F5E9'); + g.addColorStop(0.5, '#F1F8E9'); + g.addColorStop(1, '#DCEDC8'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 40; + const cardY = 100; + const cardW = WIDTH - 80; + const cardH = 400; + ctx.fillStyle = '#FFFFFF'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 22); + ctx.fill(); + ctx.strokeStyle = '#81C784'; + ctx.lineWidth = 1.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 22); + ctx.stroke(); + ctx.fillStyle = '#66BB6A'; + drawRoundRect(ctx, cardX + (cardW - 70) / 2, cardY - 8, 70, 16, 8); + ctx.fill(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { color: '#2E7D32', fontSize: 26 }); +} + +/** 10. 日落黄昏 */ +function renderSunset(ctx: CanvasRenderingContext2D, lines: string[]) { + const g = ctx.createLinearGradient(0, 0, 0, HEIGHT); + g.addColorStop(0, '#FFE0B2'); + g.addColorStop(0.4, '#FFCC80'); + g.addColorStop(0.7, '#FFB74D'); + g.addColorStop(1, '#FFA726'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 44; + const cardY = 110; + const cardW = WIDTH - 88; + const cardH = 380; + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 24); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,152,0,0.3)'; + ctx.lineWidth = 1; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 24); + ctx.stroke(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { color: '#E65100', fontSize: 26 }); +} + +/** 11. 渐变蓝 */ +function renderGradientBlue(ctx: CanvasRenderingContext2D, lines: string[]) { + const g = ctx.createLinearGradient(0, 0, WIDTH, HEIGHT); + g.addColorStop(0, '#E3F2FD'); + g.addColorStop(0.5, '#BBDEFB'); + g.addColorStop(1, '#90CAF9'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 44; + const cardY = 100; + const cardW = WIDTH - 88; + const cardH = 400; + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.fill(); + ctx.strokeStyle = 'rgba(33,150,243,0.25)'; + ctx.lineWidth = 1; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.stroke(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { color: '#0D47A1', fontSize: 26 }); +} + +/** 12. 引用卡片(大引号) */ +function renderQuoteCard(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFFDE7'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 48; + const cardY = 100; + const cardW = WIDTH - 96; + const cardH = 420; + ctx.fillStyle = '#FFFFFF'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 0.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.stroke(); + ctx.font = `bold 72px ${FONT_FALLBACK}`; + ctx.fillStyle = 'rgba(0,0,0,0.08)'; + ctx.textAlign = 'left'; + ctx.fillText('"', cardX + 20, cardY + 72); + ctx.font = `bold 24px ${FONT_FALLBACK}`; + ctx.fillStyle = '#000000'; + let y = cardY + 100; + const maxW = cardW - 80; + for (const line of lines) { + const wrapped = wrapText(ctx, line, maxW, 2); + for (const seg of wrapped) { + ctx.fillText(seg, cardX + 48, y); + y += 34; + } + } +} + +/** 13. 低质量社交不如高质量独处:白卡 + 大标题 + 蓝色插画区 + 灰色按钮 */ +function renderQualitySolitude(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 40; + const cardY = 40; + const cardW = WIDTH - 80; + const cardH = HEIGHT - 80; + ctx.strokeStyle = 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 0.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 16); + ctx.stroke(); + const pad = 36; + ctx.font = `bold 32px ${FONT_FALLBACK}`; + ctx.fillStyle = '#000000'; + ctx.textAlign = 'left'; + let y = cardY + 52; + const head1 = lines[0] || '低质量的社交'; + const head2 = lines[1] || '不如高质量的独处'; + ctx.fillText(head1, cardX + pad, y); + y += 44; + ctx.fillText(head2, cardX + pad, y); + y += 36; + ctx.font = `18px ${FONT_FALLBACK}`; + ctx.fillStyle = '#333'; + const sub = lines[2] || '(学会享受孤独,是变强的开始)'; + ctx.fillText(sub, cardX + pad, y); + y += 44; + const blockH = 220; + ctx.fillStyle = '#4285F4'; + drawRoundRect(ctx, cardX + pad, y, cardW - pad * 2, blockH, 12); + ctx.fill(); + const bx = cardX + pad + (cardW - pad * 2) / 2; + const by = y + blockH / 2; + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.beginPath(); + ctx.arc(bx - 28, by - 50, 24, 0, Math.PI * 2); + ctx.fill(); + ctx.fillRect(bx - 42, by - 18, 84, 56); + ctx.beginPath(); + ctx.arc(bx + 20, by - 30, 14, 0, Math.PI * 2); + ctx.fill(); + y += blockH + 24; + ctx.fillStyle = '#5F6368'; + drawRoundRect(ctx, cardX + pad, y, 160, 36, 18); + ctx.fill(); + ctx.font = `14px ${FONT_FALLBACK}`; + ctx.fillStyle = '#FFFFFF'; + ctx.textAlign = 'center'; + ctx.fillText('查看原图', cardX + pad + 80, y + 24); + ctx.fillStyle = '#9AA0A6'; + ctx.font = '20px sans-serif'; + ctx.fillText('☆', cardX + cardW - 70, cardY + cardH - 28); + ctx.fillText('↻', cardX + cardW - 36, cardY + cardH - 28); + ctx.fillStyle = '#5F6368'; + ctx.fillRect(cardX + cardW - 44, cardY + 16, 28, 28); + ctx.fillStyle = '#fff'; + ctx.fillRect(cardX + cardW - 38, cardY + 22, 4, 4); + ctx.fillRect(cardX + cardW - 32, cardY + 22, 4, 4); + ctx.fillRect(cardX + cardW - 26, cardY + 22, 4, 4); +} + +/** 14. 橙色便签:米白卡 + 橙色底 + Text Note + 底部分隔线 */ +function renderTextNoteOrange(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const orangePad = 44; + ctx.fillStyle = '#FF9800'; + drawRoundRect(ctx, orangePad + 8, 100, WIDTH - orangePad * 2 - 8, HEIGHT - 180, 28); + ctx.fill(); + const cardX = orangePad + 24; + const cardY = 88; + const cardW = WIDTH - orangePad * 2 - 48; + const cardH = HEIGHT - 200; + ctx.fillStyle = '#FFFBF5'; + ctx.shadowColor = 'rgba(0,0,0,0.12)'; + ctx.shadowBlur = 12; + ctx.shadowOffsetY = 4; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.fill(); + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + ctx.strokeStyle = 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 0.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.stroke(); + ctx.fillStyle = '#BDBDBD'; + ctx.font = `20px ${FONT_FALLBACK}`; + ctx.textAlign = 'left'; + ctx.fillText('⋯', cardX + 20, cardY + 36); + ctx.fillStyle = '#C4A77D'; + ctx.textAlign = 'right'; + ctx.fillText('Text Note', cardX + cardW - 24, cardY + 36); + const mainText = lines[0] || '测试'; + ctx.font = `bold 56px ${FONT_FALLBACK}`; + ctx.fillStyle = '#000000'; + ctx.textAlign = 'center'; + const mw = ctx.measureText(mainText).width; + ctx.fillText(mainText, cardX + cardW / 2, cardY + cardH / 2 + 20); + ctx.strokeStyle = '#C4A77D'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cardX + cardW * 0.15, cardY + cardH - 48); + ctx.lineTo(cardX + cardW * 0.82, cardY + cardH - 48); + ctx.stroke(); +} + +/** 15. 粉色引号:粉底 + 黄色大引号 + 居中主文 + 底部 SHARE YOUR LIFE HERE */ +function renderQuotePink(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#E890D8'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.font = `bold 120px ${FONT_FALLBACK}`; + ctx.fillStyle = '#FFEB3B'; + ctx.textAlign = 'left'; + ctx.fillText('"', 48, 160); + const mainText = lines[0] || '测试'; + ctx.font = `bold 64px ${FONT_FALLBACK}`; + ctx.fillStyle = '#1a1a1a'; + ctx.textAlign = 'center'; + ctx.fillText(mainText, WIDTH / 2, HEIGHT / 2 + 24); + ctx.font = '14px sans-serif'; + ctx.fillStyle = '#FFC107'; + ctx.textAlign = 'center'; + ctx.fillText('SHARE YOUR LIFE HERE. ▲', WIDTH / 2, HEIGHT - 48); +} + +/** 16. 像素风:深紫灰网格底 + 左上 + 居中白字 */ +function renderPixelNote(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#282436'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const gridStep = 16; + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + ctx.lineWidth = 0.5; + for (let x = 0; x <= WIDTH; x += gridStep) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, HEIGHT); + ctx.stroke(); + } + for (let y = 0; y <= HEIGHT; y += gridStep) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(WIDTH, y); + ctx.stroke(); + } + ctx.font = 'bold 22px "Courier New", "Monaco", monospace'; + ctx.fillStyle = '#9B82C4'; + ctx.textAlign = 'left'; + ctx.fillText('', 40, 72); + const mainText = lines[0] || '测试'; + ctx.font = `bold 72px ${FONT_FALLBACK}`; + ctx.fillStyle = '#FFFFFF'; + ctx.textAlign = 'center'; + ctx.fillText(mainText, WIDTH / 2, HEIGHT / 2 + 28); +} + +/** 17. 关键词型:纯干货/保姆级/超实用,夸张字体+高对比色 */ +function renderKeywordStyle(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const mainText = lines[0] || '纯干货'; + const subText = lines[1] || '保姆级教程'; + ctx.font = `bold 56px ${FONT_FALLBACK}`; + ctx.fillStyle = '#FFD93D'; + ctx.textAlign = 'center'; + ctx.fillText(mainText, WIDTH / 2, HEIGHT / 2 - 20); + ctx.font = `bold 32px ${FONT_FALLBACK}`; + ctx.fillStyle = '#FFFFFF'; + ctx.fillText(subText, WIDTH / 2, HEIGHT / 2 + 48); + ctx.font = `18px ${FONT_FALLBACK}`; + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText('超实用 · 建议收藏', WIDTH / 2, HEIGHT / 2 + 100); +} + +/** 18. 文艺书摘:柔和配色、大引号、多行正文、留白多 */ +function renderLiteraryQuote(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#F8F6F3'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 44; + const cardY = 80; + const cardW = WIDTH - 88; + const cardH = HEIGHT - 160; + ctx.fillStyle = '#FFFFFF'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.05)'; + ctx.lineWidth = 0.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 20); + ctx.stroke(); + ctx.font = `bold 80px ${FONT_FALLBACK}`; + ctx.fillStyle = 'rgba(180,160,140,0.35)'; + ctx.textAlign = 'left'; + ctx.fillText('"', cardX + 24, cardY + 88); + ctx.font = `24px ${FONT_FALLBACK}`; + ctx.fillStyle = '#4a4a4a'; + ctx.textAlign = 'left'; + let y = cardY + 120; + const maxW = cardW - 80; + for (const line of lines) { + const wrapped = wrapText(ctx, line, maxW, 3); + for (const seg of wrapped) { + ctx.fillText(seg, cardX + 48, y); + y += 32; + } + y += 8; + } + ctx.font = `16px ${FONT_FALLBACK}`; + ctx.fillStyle = '#999'; + ctx.textAlign = 'right'; + ctx.fillText('— 书摘', cardX + cardW - 40, cardY + cardH - 32); +} + +/** 19. 清新氧气感:浅蓝/薄荷、通透、留白 */ +function renderFreshOxygen(ctx: CanvasRenderingContext2D, lines: string[]) { + const g = ctx.createLinearGradient(0, 0, 0, HEIGHT); + g.addColorStop(0, '#E8F4F8'); + g.addColorStop(0.5, '#F0F9F4'); + g.addColorStop(1, '#E0F2F0'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 48; + const cardY = 100; + const cardW = WIDTH - 96; + const cardH = 380; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 24); + ctx.fill(); + ctx.strokeStyle = 'rgba(100,180,180,0.2)'; + ctx.lineWidth = 1; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 24); + ctx.stroke(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { color: '#2d5a5a', fontSize: 26 }); +} + +/** 20. 时尚潮酷:非对称、粗体、高对比色块 */ +function renderFashionTrendy(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.fillStyle = '#FF3366'; + ctx.fillRect(0, 0, WIDTH, 8); + ctx.fillRect(0, HEIGHT - 8, WIDTH, 8); + const blockW = 200; + ctx.fillStyle = '#FF3366'; + drawRoundRect(ctx, WIDTH - blockW - 40, 80, blockW, 120, 12); + ctx.fill(); + ctx.font = `bold 36px ${FONT_FALLBACK}`; + ctx.fillStyle = '#FFFFFF'; + ctx.textAlign = 'left'; + let y = 140; + const leftPad = 44; + const maxW = WIDTH - leftPad - 60; + for (const line of lines) { + const wrapped = wrapText(ctx, line, maxW, 1); + for (const seg of wrapped) { + ctx.fillText(seg, leftPad, y); + y += 44; + } + } + ctx.font = '12px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.textAlign = 'right'; + ctx.fillText('TRENDY', WIDTH - 44, HEIGHT - 32); +} + +/** 21. 可爱卡通:更活泼、多贴纸位、明亮色 */ +function renderCuteCartoon(ctx: CanvasRenderingContext2D, lines: string[]) { + ctx.fillStyle = '#FFF0F5'; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + const cardX = 36; + const cardY = 80; + const cardW = WIDTH - 72; + const cardH = 420; + ctx.fillStyle = '#FFFFFF'; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 32); + ctx.fill(); + ctx.strokeStyle = '#FF69B4'; + ctx.lineWidth = 2.5; + drawRoundRect(ctx, cardX, cardY, cardW, cardH, 32); + ctx.stroke(); + ctx.fillStyle = '#FF69B4'; + drawRoundRect(ctx, cardX + 24, cardY - 6, 100, 24, 12); + ctx.fill(); + drawCardText(ctx, lines, cardX, cardY, cardW, cardH, { fontSize: 26 }); + const stickers = [ + { x: cardX + 56, y: cardY + cardH - 56, color: '#FFE4EC' }, + { x: cardX + cardW - 56, y: cardY + 80, color: '#E6E6FA' }, + { x: cardX + cardW - 70, y: cardY + cardH - 70, color: '#FFFACD' }, + ]; + for (const s of stickers) { + ctx.fillStyle = s.color; + ctx.beginPath(); + ctx.arc(s.x, s.y, 28, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#FFB6C1'; + ctx.lineWidth = 1; + ctx.stroke(); + } + ctx.font = `18px ${FONT_FALLBACK}`; + ctx.fillStyle = '#C71585'; + ctx.textAlign = 'center'; + ctx.fillText('✨', cardX + cardW - 56, cardY + 84); + ctx.fillText('★', cardX + cardW - 70, cardY + cardH - 66); +} + +const RENDERERS: Record void> = { + 'grid-card': renderGridCard, + 'minimal-white': renderMinimalWhite, + 'gradient-soft': renderGradientSoft, + 'memo-note': renderMemoNote, + 'dark-mode': renderDarkMode, + 'pastel-cute': renderPastelCute, + 'magazine': renderMagazine, + 'retro-study': renderRetroStudy, + 'fresh-melon': renderFreshMelon, + 'sunset': renderSunset, + 'gradient-blue': renderGradientBlue, + 'quote-card': renderQuoteCard, + 'quality-solitude': renderQualitySolitude, + 'text-note-orange': renderTextNoteOrange, + 'quote-pink': renderQuotePink, + 'pixel-note': renderPixelNote, + 'keyword-style': renderKeywordStyle, + 'literary-quote': renderLiteraryQuote, + 'fresh-oxygen': renderFreshOxygen, + 'fashion-trendy': renderFashionTrendy, + 'cute-cartoon': renderCuteCartoon, +}; + +/** + * 根据模板 ID 生成封面图,返回 PNG Buffer + */ +export function generateXhsCover(templateId: string, options: XhsCoverOptions = {}): Buffer { + const lines = options.lines && options.lines.length > 0 ? options.lines : DEFAULT_LINES; + const render = RENDERERS[templateId]; + if (!render) { + throw new Error(`Unknown template: ${templateId}. Valid: ${Object.keys(RENDERERS).join(', ')}`); + } + const canvas = createCanvas(WIDTH, HEIGHT); + const ctx = canvas.getContext('2d'); + render(ctx, lines); + return canvas.toBuffer('image/png'); +} diff --git a/backend/src/types/ai.ts b/backend/src/types/ai.ts new file mode 100644 index 0000000..e34fbd7 --- /dev/null +++ b/backend/src/types/ai.ts @@ -0,0 +1,53 @@ +/** + * AI 服务相关类型定义 + */ + +// 任务状态 +export type TaskStatus = + | 'pending' + | 'content_generating' + | 'completed' + | 'failed'; + +// 大纲结构(书籍解析用) +export interface OutlineChapter { + title: string; + nodes: OutlineNode[]; +} + +export interface OutlineNode { + title: string; + suggestedContent?: string; +} + +export interface Outline { + summary?: string; + chapters: OutlineChapter[]; +} + +// 任务状态响应 +export interface TaskStatusResponse { + taskId: string; + courseId: string; + status: TaskStatus; + progress: number; + currentStep?: string; + errorMessage?: string; +} + +// 书籍解析(一步生成)豆包返回格式 +// 统一为 chaptered_content:两层时多个 chapter,单层时 1 个 chapter、parent_title 为空、sections 为各单元 + +export interface ChapteredContentItem { + parent_title: string; // 单层时可为 "" + sections: Array<{ + section_title: string; + section_interpretation: string; + }>; +} + +export interface ChapteredResponse { + chaptered_content: ChapteredContentItem[]; +} + +export type BookContentResponse = ChapteredResponse; diff --git a/backend/src/utils/courseTheme.ts b/backend/src/utils/courseTheme.ts new file mode 100644 index 0000000..99b8055 --- /dev/null +++ b/backend/src/utils/courseTheme.ts @@ -0,0 +1,44 @@ +/** + * 课程主题色和水印生成工具 + * 与前端 DesignSystem 保持一致 + */ + +/** + * 生成课程主题色(基于课程ID哈希) + * 返回 Hex 颜色字符串(如 "#2266FF") + * 与前端 DesignSystem.courseCardGradients 的主色保持一致 + */ +export function generateThemeColor(courseId: string): string { + // 与前端 DesignSystem 保持一致:品牌蓝 + 多邻国绿/紫/红/黄/橙(design.duolingo.com) + const colors = [ + '#2266FF', // 品牌蓝(保留) + '#58CC02', // Duolingo Feather Green + '#8A4FFF', // 电光紫 Cyber Iris + '#FF4B4B', // Duolingo Cardinal Red + '#FFC800', // Duolingo Bee Yellow + '#FF9600', // Duolingo Fox Orange + ]; + + // 使用与前端相同的哈希算法(基于课程ID) + let hash = 0; + for (let i = 0; i < courseId.length; i++) { + hash = ((hash * 31) + courseId.charCodeAt(i)) & 0x7fffffff; + } + const index = Math.abs(hash) % colors.length; + return colors[index]; +} + +/** + * 生成水印图标名称(基于课程类型) + * 返回 SF Symbol 名称(如 "book.closed.fill") + * 与前端 CourseModels.swift 中的 watermarkIcon 保持一致 + */ +export function generateWatermarkIcon(type: string = 'system'): string { + // 与前端 CourseModels.swift 中的 watermarkIcon 保持一致 + const iconMap: Record = { + system: 'book.closed.fill', + single: 'doc.text.fill', + vertical_screen: 'rectangle.portrait.fill', + }; + return iconMap[type] || 'doc.text.fill'; +} diff --git a/backend/src/utils/digitalIdGenerator.ts b/backend/src/utils/digitalIdGenerator.ts new file mode 100644 index 0000000..1df1130 --- /dev/null +++ b/backend/src/utils/digitalIdGenerator.ts @@ -0,0 +1,53 @@ +import prisma from './prisma'; + +/** + * 生成赛博学习证ID (Wild ID) + * 格式: 8位数字,从 10000000 开始 + * 例如: "89757" -> 实际存储为 "89757",显示时加上 "WID: " 前缀 + * + * @returns 8位数字字符串(如 "89757123") + */ +export function generateDigitalId(): string { + // 生成8位数字,从 10000000 开始 + const min = 10000000; + const max = 99999999; + const id = Math.floor(Math.random() * (max - min + 1)) + min; + return id.toString(); +} + +/** + * 生成唯一的 digitalId(确保不重复) + * @returns 唯一的8位数字字符串 + */ +export async function generateUniqueDigitalId(): Promise { + let digitalId: string; + let isUnique = false; + let attempts = 0; + const maxAttempts = 10; + + // 确保生成的 ID 唯一 + while (!isUnique && attempts < maxAttempts) { + digitalId = generateDigitalId(); + const existing = await prisma.user.findUnique({ + where: { digitalId }, + }); + if (!existing) { + isUnique = true; + return digitalId; + } + attempts++; + } + + // 如果10次尝试都失败,抛出错误 + throw new Error('无法生成唯一的 digitalId,请重试'); +} + +/** + * 格式化显示 digitalId + * @param digitalId 原始ID(如 "89757123") + * @returns 格式化后的ID(如 "WID: 89757123") + */ +export function formatDigitalId(digitalId: string | null): string | null { + if (!digitalId) return null; + return `WID: ${digitalId}`; +} diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..667ff8d --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,88 @@ +import winston from 'winston'; + +const logFormat = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() +); + +// 安全的 JSON.stringify,处理循环引用 +const safeStringify = (obj: any): string => { + const seen = new WeakSet(); + return JSON.stringify(obj, (key, value) => { + // 跳过循环引用 + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + // 处理 Error 对象,只保留有用的信息 + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + // 处理 axios 错误对象,移除循环引用 + if (value && typeof value === 'object' && 'request' in value && 'response' in value) { + return { + message: value.message, + code: value.code, + config: value.config ? { + url: value.config.url, + method: value.config.method, + baseURL: value.config.baseURL, + } : undefined, + response: value.response ? { + status: value.response.status, + statusText: value.response.statusText, + data: value.response.data, + } : undefined, + }; + } + return value; + }); +}; + +const consoleFormat = winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + let msg = `${timestamp} [${level}]: ${message}`; + if (Object.keys(meta).length > 0) { + try { + msg += ` ${safeStringify(meta)}`; + } catch (err) { + // 如果仍然失败,只记录错误信息 + msg += ` [无法序列化元数据: ${err instanceof Error ? err.message : String(err)}]`; + } + } + return msg; + }) +); + +export const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: logFormat, + defaultMeta: { service: 'wild-growth-api' }, + transports: [ + new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), + new winston.transports.File({ filename: 'logs/combined.log' }), + ], +}); + +if (process.env.NODE_ENV !== 'production') { + logger.add( + new winston.transports.Console({ + format: consoleFormat, + }) + ); +} + + + + + diff --git a/backend/src/utils/prisma.ts b/backend/src/utils/prisma.ts new file mode 100644 index 0000000..87ed032 --- /dev/null +++ b/backend/src/utils/prisma.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from '@prisma/client'; +import dotenv from 'dotenv'; + +// 确保加载环境变量 +dotenv.config(); + +const prisma = new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + datasources: { + db: { + url: process.env.DATABASE_URL, + }, + }, +}); + +export default prisma; + diff --git a/backend/src/utils/requestHelpers.ts b/backend/src/utils/requestHelpers.ts new file mode 100644 index 0000000..e45d456 --- /dev/null +++ b/backend/src/utils/requestHelpers.ts @@ -0,0 +1,28 @@ +/** + * 请求参数辅助函数 + * 用于安全地提取和转换 Express 请求参数 + */ + +/** + * 从请求参数中提取字符串值 + * @param param Express 请求参数(可能是 string 或 string[]) + * @returns 字符串值,如果是数组则取第一个元素 + */ +export function getStringParam(param: string | string[] | undefined): string { + if (!param) { + throw new Error('参数缺失'); + } + return Array.isArray(param) ? param[0] : param; +} + +/** + * 从请求参数中提取可选的字符串值 + * @param param Express 请求参数(可能是 string 或 string[]) + * @returns 字符串值或 undefined + */ +export function getOptionalStringParam(param: string | string[] | undefined): string | undefined { + if (!param) { + return undefined; + } + return Array.isArray(param) ? param[0] : param; +} diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts new file mode 100644 index 0000000..87f4427 --- /dev/null +++ b/backend/src/utils/validation.ts @@ -0,0 +1,57 @@ +/** + * 参数验证工具函数 + */ + +import { CustomError } from '../middleware/errorHandler'; + +/** + * 验证 sourceType 参数 + */ +export function validateSourceType(sourceType?: string): void { + if (sourceType) { + const validSourceTypes = ['direct', 'document', 'continue']; + if (!validSourceTypes.includes(sourceType)) { + throw new CustomError( + `无效的 sourceType,必须是: ${validSourceTypes.join(', ')}`, + 400 + ); + } + } +} + +/** + * 验证 persona 参数 + */ +export function validatePersona(persona?: string): void { + if (persona) { + const validPersonas = [ + // 直接生成的三个豆包 Lite 选项 + 'direct_test_lite', 'direct_test_lite_outline', 'direct_test_lite_summary', + // 文本解析的三个独立 Prompt 选项 + 'text_parse_xiaohongshu', 'text_parse_xiaolin', 'text_parse_douyin', + // 续旧课的三个独立 Prompt 选项 + 'continue_course_xiaohongshu', 'continue_course_xiaolin', 'continue_course_douyin', + ]; + if (!validPersonas.includes(persona)) { + throw new CustomError( + `无效的 persona,必须是: ${validPersonas.join(', ')}`, + 400 + ); + } + } +} + +/** + * 验证 sourceText 参数 + */ +export function validateSourceText(sourceText: unknown): string { + if (!sourceText || typeof sourceText !== 'string' || sourceText.trim().length === 0) { + throw new CustomError('原始文本不能为空', 400); + } + + if (sourceText.length > 20_000_000) { + throw new CustomError('原始文本过长,最多支持 2000 万字符', 400); + } + + return sourceText.trim(); +} diff --git a/backend/test-notebook-api.js b/backend/test-notebook-api.js new file mode 100644 index 0000000..f67f9ec --- /dev/null +++ b/backend/test-notebook-api.js @@ -0,0 +1,197 @@ +/** + * 测试 Notebook API 和 Note API 扩展 + * 使用方法:需要先启动服务器,然后运行此脚本 + * node test-notebook-api.js + */ + +const axios = require('axios'); + +const BASE_URL = 'http://localhost:3000'; +let authToken = ''; +let testUserId = ''; + +// 测试用户登录(需要先有一个测试用户) +async function login() { + try { + // 这里需要替换为实际的测试用户手机号和验证码 + // 或者使用已有的 token + console.log('⚠️ 请先手动获取 token,或修改此脚本使用实际的登录接口'); + console.log(' 示例:使用 Postman 或 curl 登录后获取 token'); + return null; + } catch (error) { + console.error('登录失败:', error.message); + return null; + } +} + +// 测试创建笔记本 +async function testCreateNotebook() { + console.log('\n📝 测试 1: 创建笔记本'); + try { + const response = await axios.post( + `${BASE_URL}/api/notebooks`, + { + title: '测试笔记本', + description: '这是一个测试笔记本', + }, + { + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + } + ); + console.log('✅ 创建笔记本成功'); + console.log(' 响应:', JSON.stringify(response.data, null, 2)); + return response.data.data.notebook; + } catch (error) { + console.error('❌ 创建笔记本失败:', error.response?.data || error.message); + return null; + } +} + +// 测试获取笔记本列表 +async function testGetNotebooks() { + console.log('\n📚 测试 2: 获取笔记本列表'); + try { + const response = await axios.get( + `${BASE_URL}/api/notebooks`, + { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + } + ); + console.log('✅ 获取笔记本列表成功'); + console.log(' 笔记本数量:', response.data.data.total); + console.log(' 笔记本列表:', JSON.stringify(response.data.data.notebooks, null, 2)); + return response.data.data.notebooks; + } catch (error) { + console.error('❌ 获取笔记本列表失败:', error.response?.data || error.message); + return null; + } +} + +// 测试创建笔记(带笔记本) +async function testCreateNoteWithNotebook(notebookId) { + console.log('\n📄 测试 3: 创建笔记(带笔记本)'); + try { + const response = await axios.post( + `${BASE_URL}/api/notes`, + { + notebook_id: notebookId, + type: 'thought', + content: '这是一条测试想法笔记', + // 不提供 course_id 和 node_id,测试独立笔记 + }, + { + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + } + ); + console.log('✅ 创建笔记成功'); + console.log(' 响应:', JSON.stringify(response.data, null, 2)); + return response.data; + } catch (error) { + console.error('❌ 创建笔记失败:', error.response?.data || error.message); + return null; + } +} + +// 测试获取笔记(按笔记本过滤) +async function testGetNotesByNotebook(notebookId) { + console.log('\n🔍 测试 4: 获取笔记(按笔记本过滤)'); + try { + const response = await axios.get( + `${BASE_URL}/api/notes?notebook_id=${notebookId}`, + { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + } + ); + console.log('✅ 获取笔记成功'); + console.log(' 笔记数量:', response.data.data.total); + console.log(' 笔记列表:', JSON.stringify(response.data.data.notes, null, 2)); + return response.data.data.notes; + } catch (error) { + console.error('❌ 获取笔记失败:', error.response?.data || error.message); + return null; + } +} + +// 测试删除笔记本(级联删除笔记) +async function testDeleteNotebook(notebookId) { + console.log('\n🗑️ 测试 5: 删除笔记本(级联删除笔记)'); + try { + const response = await axios.delete( + `${BASE_URL}/api/notebooks/${notebookId}`, + { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + } + ); + console.log('✅ 删除笔记本成功'); + console.log(' 响应:', JSON.stringify(response.data, null, 2)); + return true; + } catch (error) { + console.error('❌ 删除笔记本失败:', error.response?.data || error.message); + return false; + } +} + +// 主测试函数 +async function runTests() { + console.log('🚀 开始测试 Notebook API 和 Note API 扩展\n'); + console.log('⚠️ 注意:此测试需要:'); + console.log(' 1. 服务器正在运行 (npm run dev)'); + console.log(' 2. 有效的认证 token'); + console.log(' 3. 测试用户已存在\n'); + + // 提示用户输入 token + if (!authToken) { + console.log('请提供认证 token(或修改脚本中的 authToken 变量)'); + console.log('示例:从浏览器开发者工具或 Postman 中获取 token\n'); + return; + } + + // 测试流程 + const notebook = await testCreateNotebook(); + if (!notebook) { + console.log('\n❌ 测试失败:无法创建笔记本'); + return; + } + + const notebooks = await testGetNotebooks(); + if (!notebooks || notebooks.length === 0) { + console.log('\n❌ 测试失败:无法获取笔记本列表'); + return; + } + + const note = await testCreateNoteWithNotebook(notebook.id); + if (!note) { + console.log('\n❌ 测试失败:无法创建笔记'); + return; + } + + const notes = await testGetNotesByNotebook(notebook.id); + if (!notes || notes.length === 0) { + console.log('\n❌ 测试失败:无法获取笔记列表'); + return; + } + + // 清理:删除测试数据 + await testDeleteNotebook(notebook.id); + + console.log('\n✅ 所有测试完成!'); +} + +// 运行测试 +if (require.main === module) { + runTests().catch(console.error); +} + +module.exports = { runTests }; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..bbf4b3e --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/__tests__/**", "**/*.test.ts", "**/*.spec.ts", "**/queueService.ts", "**/utils/redis.ts"] +} + + + + + diff --git a/docs/tech-plan-persona-cleanup-and-lite-options.md b/docs/tech-plan-persona-cleanup-and-lite-options.md new file mode 100644 index 0000000..04c0222 --- /dev/null +++ b/docs/tech-plan-persona-cleanup-and-lite-options.md @@ -0,0 +1,126 @@ +# 技术方案:删除「直接生成测试版」+ 新增「豆包lite-大纲 / 豆包lite-总结」 + +## 一、目标 + +1. **删除**「直接生成测试版」相关前后端代码。 +2. **新增**两个选项:「直接测试-豆包lite-大纲」「直接测试-豆包lite-总结」,前后端全支持。 +3. **收尾**:推 Git,阿里云部署。 + +--- + +## 二、现状梳理 + +| 层级 | 直接生成测试版 (direct_test) | 直接测试-豆包lite (direct_test_lite) | +|------|------------------------------|--------------------------------------| +| **后端** | `direct_generation_test` Prompt 类型;`validPersonas` 含 `direct_test`;生成时 `persona === 'direct_test'` 用该 Prompt、默认模型 | 同用 `direct_generation_test` Prompt;`persona === 'direct_test_lite'` 用 `DOUBAO_MODEL_LITE` | +| **前端 iOS** | `PersonaType.directTest`;PersonaSelectionView 里一张卡片 | `PersonaType.directTestLite`;PersonaSelectionView 里一张卡片 | +| **管理后台** | course-admin 里「直接生成测试版 Prompt」配置项 | 无单独配置(与 direct_test 共用 Prompt) | + +- 数据库:`course_generation_tasks.persona` 为 String,无枚举约束,**加删 persona 值不需要 DB 迁移**。 +- 当前直接生成流程:一次调用得到 `chaptered_content`(大纲+节解释),再 `contentService.generateAllContent` 填内容;没有单独的「只生成大纲」或「只生成总结」接口,新选项在现有流程上通过 **persona + 可选不同 Prompt** 区分即可。 + +--- + +## 三、方案一:删除「直接生成测试版」+ 新增两个 lite 选项(推荐) + +### 3.1 删除「直接生成测试版」 + +**后端** + +- **promptConfigService.ts** + - `PromptType` 去掉 `'direct_generation_test'`。 + - 删除 `DIRECT_GENERATION_TEST`、默认 `direct_generation_test` 的 default prompt、`getPromptKey`/`getDefaultPrompt`/`getPromptTemplate` 中对该类型的处理。 +- **courseGenerationService.ts** + - 删除对 `persona === 'direct_test'` 的分支;只保留对 `direct_test_lite*` 的判断(见下)。 + - 原「直接生成测试版」与「直接测试-豆包lite」共用的逻辑改为:仅当 persona 为 `direct_test_lite` / `direct_test_lite_outline` / `direct_test_lite_summary` 时使用「测试用 Prompt」+ 豆包 Lite 模型(见下)。 +- **validation.ts** + - `validPersonas` 去掉 `'direct_test'`,加入 `'direct_test_lite_outline'`、`'direct_test_lite_summary'`(若保留原有 `direct_test_lite` 则也保留)。 +- **promptController.ts** + - 类型与 PROMPT_TYPE_MAP 等中去掉 `direct_generation_test`;若保留一个「lite 用」的 Prompt,可重命名为例如 `direct_generation_lite` 或保留一名给 lite 共用(见下)。 +- **course-admin.html** + - Prompt 配置列表去掉「直接生成测试版 Prompt」;若新增「豆包lite-大纲/总结」专用 Prompt,则在列表中增加对应项并改说明文案。 + +**iOS** + +- **AICourseModels.swift** + - 删除 `case directTest = "direct_test"`。 +- **PersonaSelectionView.swift** + - 删除「直接生成测试版」那张 `CleanPersonaCard`,只保留/新增「直接测试-豆包lite」「直接测试-豆包lite-大纲」「直接测试-豆包lite-总结」等(见下)。 + +### 3.2 新增「直接测试-豆包lite-大纲」「直接测试-豆包lite-总结」 + +**选项 A:最小实现(推荐先做)** + +- 两个新选项仅作「人格/入口」区分,**共用同一套 Prompt 和同一豆包 Lite 模型**,便于先上线、后补差异化 Prompt。 +- 后端: + - `validPersonas` 增加:`direct_test_lite_outline`、`direct_test_lite_summary`。 + - `courseGenerationService`: + - 模型:`persona === 'direct_test_lite' || persona === 'direct_test_lite_outline' || persona === 'direct_test_lite_summary'` → 使用 `DOUBAO_MODEL_LITE`。 + - Prompt:上述 persona 均使用同一个「lite 用」Prompt(例如保留并重命名原 `direct_generation_test` 为 `direct_generation_lite`,或新键 `direct_generation_lite`,仅一份模板)。 + - 管理后台:保留/改为一个「直接生成 Lite Prompt」(或「豆包 Lite 测试用 Prompt」),供三个 lite 选项共用。 +- iOS: + - `PersonaType` 增加:`directTestLiteOutline`、`directTestLiteSummary`(保留或删除 `directTestLite` 按产品决定)。 + - PersonaSelectionView 在「直接生成」流程下展示两张新卡片:「直接测试-豆包lite-大纲」「直接测试-豆包lite-总结」(若保留「直接测试-豆包lite」则三张)。 +- 后续若需要「大纲」和「总结」在生成效果上真正区分,再为这两个 persona 各配不同 Prompt(见选项 B)。 + +**选项 B:大纲/总结用不同 Prompt(可选、后续迭代)** + +- 后端新增两个 Prompt 类型,例如:`direct_generation_lite_outline`、`direct_generation_lite_summary`。 +- `promptConfigService`、`promptController`、course-admin 中增加这两种类型的配置与编辑。 +- `courseGenerationService` 中按 persona 选择 Prompt: + - `direct_test_lite_outline` → `direct_generation_lite_outline` + - `direct_test_lite_summary` → `direct_generation_lite_summary` + - (若仍保留 `direct_test_lite`)→ 共用 `direct_generation_lite` 或其一。 +- 模型仍为豆包 Lite,仅 Prompt 不同。iOS 无需改接口,只传不同 persona。 + +### 3.3 建议实施顺序 + +1. **后端**:删除 `direct_test` 与 `direct_generation_test` 全部引用;`validPersonas` 改为只含 `direct_test_lite`、`direct_test_lite_outline`、`direct_test_lite_summary`(或按产品二选一:只保留后两个)。 +2. **后端**:为 lite 统一使用一个 Prompt 类型(如 `direct_generation_lite`),courseGenerationService 与 promptController 只认这一种;管理后台只保留一个 Lite 用 Prompt 配置。 +3. **iOS**:删除 `directTest`,新增 `directTestLiteOutline` / `directTestLiteSummary`,PersonaSelectionView 只展示两个新卡片(或加「直接测试-豆包lite」共三个)。 +4. **联调**:创建课程选「豆包lite-大纲」「豆包lite-总结」各跑通一次,确认进度与结果正常。 +5. **管理后台**:文案与列表项与上述一致(删除直接生成测试版、保留/新增 Lite 用 Prompt)。 + +--- + +## 四、Git 与阿里云部署 + +- **Git** + - 在功能分支完成上述改动,自测通过后合并到主分支(如 `main`)。 + - 提交信息建议包含:`feat: 移除直接生成测试版,新增豆包lite-大纲/总结选项`。 +- **阿里云部署** + - 当前脚本:`backend/deploy/deploy-from-github.sh`(从 GitHub 拉取后 build + pm2 restart)。 + - 步骤: + `ssh root@<阿里云主机>` → `cd /var/www/wildgrowth-backend/backend` → `bash deploy/deploy-from-github.sh [分支名]`。 + - 若 pull 超时,可使用 `deploy-rsync-from-local.sh` 从本机 rsync 代码再在服务器上 build 并重启。 + - 部署后验证:管理后台能打开、Prompt 配置正常;iOS 选「豆包lite-大纲」或「豆包lite-总结」创建课程,任务能正常跑完并在列表中可见。 + +--- + +## 五、风险与注意点 + +- **历史任务**:已有 `persona = 'direct_test'` 或 `'direct_generation_test'` 的旧任务仅作历史数据,新逻辑不再处理;若管理后台有按 persona 展示,需把「直接生成测试版」改为已废弃或隐藏。 +- **AppConfig 表**:若曾把 `direct_generation_test_prompt` 存进配置,删除类型后对应 key 可保留不删,避免误删其它配置;或单独清理脚本只删该 key。 +- **iOS 与后端 persona 枚举**:两边需完全一致(`direct_test_lite_outline` / `direct_test_lite_summary` 等),否则接口会报 persona 校验失败。 + +--- + +## 六、小结 + +| 步骤 | 内容 | +|------|------| +| 1 | 后端删除 `direct_test`、`direct_generation_test`;validPersonas 与生成逻辑只保留 lite 系 persona | +| 2 | 后端为 lite 统一一个 Prompt 类型(如 `direct_generation_lite`),并支持 `direct_test_lite_outline`、`direct_test_lite_summary` | +| 3 | iOS 删除 directTest,新增 directTestLiteOutline、directTestLiteSummary,选择页只展示新选项 | +| 4 | 管理后台 Prompt 列表与说明文案同步 | +| 5 | 推 Git,阿里云执行 deploy-from-github.sh(或 rsync 后 build + 重启) | + +--- + +## 七、已实施(本期) + +- 已删除「直接生成测试版」(direct_test) 及 `direct_generation_test` Prompt 类型。 +- 已保留「直接测试-豆包lite」(direct_test_lite),并新增「直接测试-豆包lite-大纲」(direct_test_lite_outline)、「直接测试-豆包lite-总结」(direct_test_lite_summary)。 +- 后端:三个 lite 选项分别使用 `direct_generation_lite`、`direct_generation_lite_outline`、`direct_generation_lite_summary` 三个 Prompt 类型,管理后台可分别配置;均使用豆包 Lite 模型,调用流程与原先直接测试-豆包lite 一致。 +- iOS:PersonaSelectionView 在直接生成流程下展示三张卡片;AICourseModels 已移除 directTest,保留 directTestLite,新增 directTestLiteOutline、directTestLiteSummary。 +- 部署:代码推 Git 后,在阿里云执行 `deploy-from-github.sh`(或 rsync 后 build + pm2 restart)即可。 diff --git a/ios/WildGrowth/COMPLETION_LogicFixed版_审查报告.md b/ios/WildGrowth/COMPLETION_LogicFixed版_审查报告.md new file mode 100644 index 0000000..34d5440 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_LogicFixed版_审查报告.md @@ -0,0 +1,50 @@ +# 「Logic Fixed」版 CompletionView 审查报告(禁止应用) + +**审查对象**:Gemini 提供的 Cyber Polaroid - Logic Fixed 版(内部持有 UserManager、点击拉取后端、参数仅 courseId/courseTitle/path)。 +**结论**:仅审查,**不应用、不修改**仓库内任何文件。 + +--- + +## 一、逻辑层与需求符合性 + +| 项目 | 要求 | 本版实现 | 结论 | +|------|------|----------|------| +| **点击按钮时拉后端** | 用户点击后,在 CompletionView 内部调接口拉最新数据 | `fetchAndDevelop()` 内 `try await userManager.fetchUserProfile()`,再显影展示 | ✅ 符合 | +| **数据权责在 CompletionView** | 不依赖父视图传入 completedLessonCount / focusMinutes | 仅入参:courseId, courseTitle, navigationPath;展示用 `userManager.studyStats.lessons`、`userManager.studyStats.time` | ✅ 符合 | +| **回到我的内容** | 必须调用 navStore.switchToGrowthTab() | `handleBackToContent()` 内先 `navStore.switchToGrowthTab()`,再清 path 或 dismiss | ✅ 符合 | +| **前端持久化动画状态** | 已显影过则直接展示结果,不重复播动画 | UserDefaults `has_revealed_course_\(courseId)`,onAppear 时 `checkDevelopmentStatus()` | ✅ 符合 | + +--- + +## 二、接口与调用方影响 + +| 项目 | 说明 | +|------|------| +| **CompletionView 入参** | courseId, courseTitle, navigationPath? — 与当前仓库中 VerticalScreenPlayerView 的调用一致,**无新增必选参数**。 | +| **VerticalScreenPlayerView** | 无需修改,现有 `CompletionView(courseId:, courseTitle:, navigationPath:)` 可直接编译。 | +| **其他页面** | 无其他调用处,逻辑与展示不受影响。 | + +结论:**仅替换 CompletionView.swift 即可,无需改其他文件。** + +--- + +## 三、实现细节核对 + +| 项目 | 说明 | +|------|------| +| **UserManager.studyStats** | 仓库中为 `(time: Int, lessons: Int)`,本版使用 `userManager.studyStats.lessons`、`userManager.studyStats.time`,字段一致。 | +| **拉取失败时** | `fetchUserProfile()` 抛错时 catch 仅 print,仍执行 `MainActor.run { isDeveloped = true; ... }`,即失败也显影并展示当前 userManager 数据,按钮不会一直 Loading,行为合理。 | +| **0.8s 延时** | 显影前 `Task.sleep(0.8s)` 为体验延时,与「先拉接口再显影」不冲突(实际应在 fetch 完成后显影,当前顺序为:sleep → fetch → 显影)。若希望「拉完再显影、无固定延时」,可去掉 sleep 或改为仅在实际请求完成后显影。 | + +--- + +## 四、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **逻辑层** | 点击拉后端、数据来自 UserManager、不新增父视图业务参数,符合「不改逻辑层」的约定。 | +| **接口** | 仅 courseId / courseTitle / navigationPath,与现有一致,调用方零改动。 | +| **其他页面** | 不受影响。 | +| **建议** | 逻辑与接口均可接受;若采用,仅全量替换 CompletionView.swift。0.8s 延时可视产品需求保留或去掉。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_MagicCard版_审查报告.md b/ios/WildGrowth/COMPLETION_MagicCard版_审查报告.md new file mode 100644 index 0000000..15682a9 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_MagicCard版_审查报告.md @@ -0,0 +1,96 @@ +# CompletionView Magic Card 版 — 审查报告(不应用) + +**审查日期**:2025-01-29 +**范围**:Magic Card Edition(粒子庆祝 + 按钮卡片一体化 + 极光光晕) +**结论**:仅审查、不修改仓库;逻辑继承正确,**ParticleModifier 存在两处需修复的实现问题**。 + +--- + +## 1. 需求落实情况 + +| 项目 | 要求 | 定稿实现 | 结论 | +|------|------|----------|------| +| **按钮卡片一体化** | 按钮在卡片内,点击后「献祭」化作彩带和数据 | IdleActionView 中按钮,切换后显示 ActiveDashboard + ConfettiExplosion | ✅ | +| **粒子爆炸** | 蓝/紫/粉色粒子炸开 | ConfettiExplosion:Circle + Capsule,confettiColors | ✅ | +| **呼吸光晕** | 激活后卡片背景极淡极光渐变 | AngularGradient + blur(20),isSystemOn 时显示 | ✅ | +| **极简流程** | 点击上传 → 按钮消失 → 数据呈现,无「已同步」占位 | 符合 | ✅ | +| **色彩规范** | 按钮 System Blue | brandBlue = Color.blue | ✅ | + +--- + +## 2. 逻辑继承 + +| 项目 | 结论 | +|------|------| +| **持久化 Key** | ✅ `has_revealed_course_\(courseId)` | +| **游客短路** | ✅ 0.5s 延迟,`performActivation(0, 0)`,不调网络 | +| **登录用户** | ✅ 0.8s 延迟 → fetchUserProfile → performActivation | +| **checkSystemStatus** | ✅ 已激活时恢复最终值 | +| **数字滚动** | ✅ `withAnimation(.linear(1.5))` + RollingNumberText | +| **ScaleButtonStyle** | 未使用,无重复定义风险 | + +--- + +## 3. ⚠️ ParticleModifier 需修复项 + +### 3.1 角度单位错误(影响粒子方向) + +**问题**:`angle` 为角度制 (0~360°),但 `cos(angle)`、`sin(angle)` 在 Swift 中接收弧度制。未做转换会导致粒子方向错误。 + +**修复**:先转换为弧度再计算: +```swift +let rad = angle * .pi / 180 +offset(x: time == 0 ? 0 : cos(rad) * distance, + y: time == 0 ? 0 : sin(rad) * distance) +``` + +### 3.2 angle / distance 每次重算导致动画抖动 + +**问题**:`var angle: Double { Double.random(in: 0...360) }` 和 `var distance: Double { ... }` 为计算属性,`body` 每次求值都会得到新随机数,粒子目标位置不断变化,动画会抖动。 + +**修复**:在 `init` 中一次性随机,并用 `@State` 保存,例如: +```swift +let index: Int +@State private var time: Double = 0.0 +private let angle: Double +private let distance: Double + +init(index: Int) { + self.index = index + self.angle = Double.random(in: 0..<360) + self.distance = Double.random(in: 80...150) + // 如需在 init 中初始化 @State,可用 _time = State(initialValue: 0) +} +``` +并在 `body` 中用 `angle`、`distance` 计算 offset(配合上述弧度转换)。 + +--- + +## 4. ConfettiExplosion 可选优化 + +- **`confettiColors.randomElement()!`**:在 `body` 中每次重绘都会重新随机,粒子颜色可能闪变。可按 `index` 固定颜色,如 `confettiColors[i % confettiColors.count]`,以保持每个粒子颜色稳定。 +- **ForEach(0..<15)**:若 Swift 版本要求,可为 `ForEach` 增加 `id: \.self`。 + +--- + +## 5. 接口与影响范围 + +| 项目 | 结论 | +|------|------| +| **初始化参数** | 三参数不变 | +| **替换方式** | 整文件替换 `CompletionView.swift` | +| **新增类型** | `ParticleModifier` 仅在本文件使用,无符号冲突 | +| **RollingNumberText** | 同前,保留在本文件 | + +--- + +## 6. 总结 + +| 维度 | 结论 | +|------|------| +| **需求落实** | ✅ 卡片一体化、粒子、光晕、流程、色彩均符合 | +| **逻辑继承** | ✅ Key、游客、登录用户、数字滚动正确 | +| **ParticleModifier** | ⚠️ **需修复**:弧度转换 + angle/distance 稳定化 | +| **接口兼容** | ✅ 可直接替换 | + +**审查结论**: Magic Card 版在设计与逻辑上正确,应用前需修复 ParticleModifier 的角度单位与随机值稳定性,否则粒子动画会出现方向错误和抖动。**本次未对仓库做任何修改。** diff --git a/ios/WildGrowth/COMPLETION_NAVIGATION_审查报告.md b/ios/WildGrowth/COMPLETION_NAVIGATION_审查报告.md new file mode 100644 index 0000000..8b16d47 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_NAVIGATION_审查报告.md @@ -0,0 +1,133 @@ +# 完成页导航方案 — 审查报告 + +**审查对象**:基于「零副作用 / SSOT / 响应式导航 / 原生手感」的完成页与播放器导航改造方案 +**审查结论**:只做审查,不修改代码;本报告供决策与后续实现参考。 + +--- + +## 一、方案目标与标准(引用) + +| 标准 | 含义 | +|-----|------| +| **零副作用** | 不引入「假页面」污染数据源,无 Hack | +| **单一事实来源 (SSOT)** | 数据源 `allCourseNodes` 仅含真实课程章节 | +| **响应式导航** | 用 `NavigationPath` 堆栈精确控制流向,消除白屏回退 | +| **原生手感** | 用手势识别(非页面索引)触发跳转 | + +--- + +## 二、方案要点摘要 + +1. **删除 CompletionPlaceholderPage 的导航职责**(或整页删除,视实现而定) + - 若保留:仅作纯视觉占位,无 `onAppear` 等导航逻辑。 + +2. **VerticalScreenPlayerView** + - 在**最后一节**挂载「边缘左滑手势」修饰符(如 `LastPageSwipeModifier`)。 + - 左滑触发:`navigationPath.append(CourseNavigation.completion(...))`,或先设 `currentNodeId = "wg://completion"` 再由现有 `onChange` 统一 push。 + - TabView 仅渲染真实 `allCourseNodes`,不把「完成」当作一节数据。 + +3. **CompletionView** + - 新增 `@Binding var navigationPath: NavigationPath`。 + - 「回到我的内容」/「继续学习」调用 `handleReturnToMap()`:`navigationPath.removeLast(2)`,一次 pop 完成页 + 播放器,直接回地图。 + +4. **调用方** + - GrowthView / ProfileView / DiscoveryView 的 `.completion` 分支向 `CompletionView` 传入对应 path 的 `Binding`(如 `$navStore.growthPath` 等)。 + +--- + +## 三、与当前实现的对照 + +| 维度 | 当前实现 | 方案 | +|------|----------|------| +| **数据源** | `allCourseNodes` 纯净;TabView 多一页 `CompletionPlaceholderPage` 用 tag `"wg://completion"`,**未**写入 `allCourseNodes` | 与当前一致或更进一步:完全移除占位页,仅手势触发 push | +| **进入完成页** | 左滑到占位页 → `onChange(of: currentNodeId)` → 0.1s 后 append `.completion` | 最后一节左滑手势 → 直接 append 或先切 tag 再 append,不依赖「多一页」 | +| **完成页返回** | `navStore.switchToGrowthTab()` + `dismiss()`,先回播放器再靠系统/逻辑 | `removeLast(2)` 穿透回地图,无中间层 | +| **占位页** | 存在,纯视觉 + 父视图 `onChange` 驱动 push,无占位内 `onAppear` | 方案 A:保留为纯视觉;方案 B:删除,仅手势 | + +**结论**:方案在「单一事实来源」和「响应式导航」上比当前更彻底;当前已避免在数据源里掺假节点,但仍依赖 TabView 多一页占位。 + +--- + +## 四、按标准的符合度 + +### 4.1 零副作用 + +- **方案**:若不保留占位页,则无「假页」;若保留占位页且仅视觉、无逻辑,则也算零逻辑副作用。 +- **当前**:占位页无 `onAppear` 导航,副作用已收敛,但 TabView 仍多一个「虚拟页」。 +- **符合度**:方案完全符合;当前基本符合,方案更干净。 + +### 4.2 单一事实来源 (SSOT) + +- **方案**:`allCourseNodes` 仅课程章节;完成页由导航栈 + 手势驱动,不进入数据模型。 +- **当前**:`allCourseNodes` 已是纯净的 `flatMap` 章节节点,未追加 placeholder node。 +- **符合度**:方案与当前都符合;方案在视图层也不再依赖「多一页」的 tag,SSOT 更纯粹。 + +### 4.3 响应式导航 + +- **方案**:`CompletionView` 通过 `Binding` 执行 `removeLast(2)`,栈由 path 唯一决定,无 `switchToGrowthTab()` + `dismiss()` 的二次操作。 +- **当前**:依赖 `dismiss()` 回播放器,再从播放器回地图,存在中间层与潜在白屏/闪烁。 +- **符合度**:方案明显更符合;当前有改进空间。 + +### 4.4 原生手感 + +- **方案**:最后一节用 `DragGesture`(或类似)识别左滑,不依赖「滑到下一 tab 索引」才触发。 +- **当前**:依赖用户滑到「占位页」才触发,仍与 TabView 索引/选页绑定。 +- **符合度**:方案更贴近「手势驱动」;当前是「页面索引 + 手势」混合。 + +--- + +## 五、与既有文档的一致性 + +### 5.1 COMPLETION_VIEW_UI_LAYER_SPEC.md + +- 说明要求:返回用 `dismiss()`,**不**接收或操作 `NavigationPath`。 +- **方案**:改为接收 `Binding` 并 `removeLast(2)`,与当前 spec 冲突。 +- **建议**:若采用方案,需**同步更新** COMPLETION_VIEW_UI_LAYER_SPEC: + - 写明「穿透式返回」为可选/推荐实现; + - 接口增加 `@Binding var navigationPath: NavigationPath`; + - 底部按钮行为改为「可调用 `removeLast(2)` 直接回地图」,并注明调用方需传入对应 path 的 Binding。 + +### 5.2 COMPLETION_PAGE_MEANING.md + +- 当前描述为:最后一节后再左滑一页进入占位页,占位页触发 push。 +- **方案**:最后一节左滑即触发(或先切 tag 再触发),可完全去掉「多一页」概念。 +- **建议**:若采用方案,更新 COMPLETION_PAGE_MEANING: + - 「最后一页」仍指课程的最后一节; + - 进入完成页的方式改为「在最后一节左滑(手势)触发」,并注明是否保留占位页。 + +--- + +## 六、风险与注意点 + +1. **多入口 path** + CompletionView 在 Growth / Profile / Discovery 三处被 present,需分别传入 `growthPath` / `profilePath` / `homePath` 的 Binding;漏传或传错会导致 `removeLast(2)` 作用在错误栈上。建议: + - 在审查/实现时逐处确认传参; + - 或为 CompletionView 封装「当前栈」来源(例如 Environment 注入当前 path),避免调用方误绑。 + +2. **NavigationPath.count** + `removeLast(2)` 前需保证 `path.count >= 2`(完成页 + 播放器)。若从深链或异常入口进入完成页,栈深度可能不足,需保留兜底(如 `dismiss()`)。 + +3. **手势与 TabView 滑动冲突** + 最后一节左滑既会触发自定义手势,也是 TabView 的翻页手势。需通过 `minimumDistance`、`coordinateSpace` 或「边缘优先」等策略区分,避免误触或重复触发。建议在真机多测:最后一节左滑、快速连续左滑、斜滑。 + +4. **从完成页返回后的 Tab 状态** + 当前实现:从完成页 dismiss 回播放器后,`onAppear` 里若 `currentNodeId == "wg://completion"` 会切回 `allCourseNodes.last?.id`。采用方案后,若使用 `removeLast(2)`,不会回到播放器,故无需再依赖该逻辑;若仍保留「先 dismiss 再回地图」的入口,需保留或等价处理该状态恢复。 + +--- + +## 七、审查结论与建议 + +| 项目 | 结论 | +|------|------| +| **架构与标准** | 方案满足「零副作用、SSOT、响应式导航、原生手感」四项标准,且比当前实现更彻底。 | +| **与现有 spec** | 与 COMPLETION_VIEW_UI_LAYER_SPEC 的「不操作 NavigationPath」冲突,需更新 spec。 | +| **实现成本** | 中等:CompletionView 接口与三处调用方改动;VerticalScreenPlayerView 增加手势与可选移除占位页;需回归测试返回与多入口。 | +| **建议** | 若采纳方案: + 1. 先更新 COMPLETION_VIEW_UI_LAYER_SPEC 与 COMPLETION_PAGE_MEANING,再改代码; + 2. CompletionView 保留 `path.count >= 2` 判断与 `dismiss()` 兜底; + 3. 手势与 TabView 的冲突在真机验证,必要时加防抖或边缘区域限制; + 4. 三处 navigationDestination 的 `Binding` 传参做清单检查,避免漏传/错传。 | + +--- + +**报告日期**:基于当前代码与所提供方案整理,未对仓库做任何代码修改。 diff --git a/ios/WildGrowth/COMPLETION_SkeletonReveal版_审查报告.md b/ios/WildGrowth/COMPLETION_SkeletonReveal版_审查报告.md new file mode 100644 index 0000000..e0a3c27 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_SkeletonReveal版_审查报告.md @@ -0,0 +1,77 @@ +# CompletionView Skeleton & Reveal 版 — 审查报告(不应用) + +**审查日期**:2025-01-29 +**范围**:Skeleton & Reveal Edition(纯白卡片 + 呼吸骨架屏 + 蓝色弥散光 + 粒子庆祝) +**结论**:仅审查、不修改仓库;需求落实正确,ParticleModifier 已按前次审查完成修复,**有一处可选视觉层级调整**。 + +--- + +## 1. 需求落实情况 + +| 项目 | 要求 | 定稿实现 | 结论 | +|------|------|----------|------| +| **纯白卡片** | 彻底去「脏」,回归纯白 | `background(Color.white)` | ✅ | +| **拒绝空状态** | 未激活时用骨架屏替代留白 | IdleSkeletonView:RoundedRectangle 骨架 + 呼吸透明度 | ✅ | +| **呼吸骨架** | 暗示「数据等你揭开」 | `isBreathing ? 0.3 : 0.6` 配合 `repeatForever(autoreverses: true)` | ✅ | +| **蓝色弥散光** | 激活后四周泛蓝光 | `shadow(color: isSystemOn ? brandBlue.opacity(0.25) : ..., radius: 40)` | ✅ | +| **彩带爆炸** | 激活瞬间粒子庆祝 | ConfettiExplosion(Circle + Capsule) | ✅ | +| **布局平衡** | 激活后数字填补按钮消失空间 | ActiveDashboard 使用 Spacer 居中,无按钮占位 | ✅ | + +--- + +## 2. ParticleModifier 修复确认 + +Magic Card 版审查中的两处问题均已修正: + +| 项目 | Magic Card 版问题 | Skeleton & Reveal 实现 | 结论 | +|------|--------------------|------------------------|------| +| **弧度转换** | `cos(angle)` 使用角度制 | `angleRad = angleDegrees * .pi / 180`,使用 `cos(angleRad)` | ✅ | +| **随机值稳定** | `angle`/`distance` 为计算属性,每次重算 | `init` 中生成并存入 `let angleRad`、`let distance` | ✅ | +| **粒子颜色** | `randomElement()!` 每次重绘变化 | `confettiColors[i % confettiColors.count]` 按 index 固定 | ✅ | + +--- + +## 3. 逻辑继承 + +| 项目 | 结论 | +|------|------| +| **持久化 Key** | ✅ `has_revealed_course_\(courseId)` | +| **游客短路** | ✅ 0.5s 延迟,`performActivation(0, 0)`,不调网络 | +| **登录用户** | ✅ 0.8s 延迟 → fetchUserProfile → performActivation | +| **checkSystemStatus** | ✅ 已激活时恢复最终值 | +| **数字滚动** | ✅ `withAnimation(.linear(1.5))` + RollingNumberText | + +--- + +## 4. 可选:粒子与文字层级 + +**当前实现**:`ConfettiExplosion` 使用 `.zIndex(2)`,卡片内容 VStack 使用默认 zIndex,因此粒子叠在文字之上。 + +**注释意图**:「粒子炸在文字后面,但在卡片背景前面」——若希望粒子在文字后面,需要降低粒子的 zIndex,或提高卡片内容的 zIndex。 + +**建议**:若需粒子在文字后面,可为卡片 VStack 添加 `.zIndex(1)`,并为 `ConfettiExplosion` 使用 `.zIndex(0)` 或不设置。若希望粒子在前面以增强庆祝感,可保持现状。属视觉偏好,非必须修改。 + +--- + +## 5. 其他实现细节 + +| 项目 | 说明 | +|------|------| +| **骨架 blur(radius: 3)** | 增加模糊感,在多数设备上可接受;低端机若有卡顿再考虑去掉 | +| **呼吸动画** | `onAppear` 中 `withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true))` 驱动 `isBreathing`,逻辑正确 | +| **ScaleButtonStyle** | 未使用,无重复定义风险 | +| **接口** | 三参数不变,可直接替换 | + +--- + +## 6. 总结 + +| 维度 | 结论 | +|------|------| +| **需求落实** | ✅ 纯白、骨架屏、弥散光、粒子、布局均符合 | +| **ParticleModifier** | ✅ 弧度、随机稳定、颜色已修正 | +| **逻辑继承** | ✅ Key、游客、登录用户、数字滚动正确 | +| **粒子层级** | ⚪ 可选:按需求调整 zIndex 以实现「粒子在文字后面」 | +| **接口兼容** | ✅ 可直接替换 | + +**审查结论**:Skeleton & Reveal 版满足设计要求,ParticleModifier 已正确修复,可直接使用;如需粒子在文字后方,可按上节建议调整 zIndex。**本次未对仓库做任何修改。** diff --git a/ios/WildGrowth/COMPLETION_Y2K版_FRC_审查报告.md b/ios/WildGrowth/COMPLETION_Y2K版_FRC_审查报告.md new file mode 100644 index 0000000..09e2f68 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_Y2K版_FRC_审查报告.md @@ -0,0 +1,64 @@ +# CompletionView Y2K 版 — 最终候选版 (FRC) 审查报告(不应用) + +**审查日期**:2025-01-29 +**范围**:Y2K Final Release Candidate(含持久化 Key 回滚 + 游客短路逻辑补全) +**结论**:仅审查、不修改仓库;两项修复已正确落实,可视为可发布候选。 + +--- + +## 1. 持久化 Key 回滚 ✅ + +| 项目 | 要求 | FRC 实现 | 结论 | +|------|------|----------|------| +| **storageKey** | 与现版一致,沿用 `has_revealed_course_\(courseId)` | `private var storageKey: String { "has_revealed_course_\(courseId)" }` | ✅ 正确 | + +从拍立得版本升级到 Y2K 后,已显影过的课程会直接显示结果,无需再次点击 SYNC。 + +--- + +## 2. 游客短路逻辑 ✅ + +| 项目 | 要求 | FRC 实现 | 结论 | +|------|------|----------|------| +| **是否调网络** | 游客不调 `fetchUserProfile` | `if userManager.isGuest { ... return }` 先判断,仅主线程延迟后 `finalizeSync()` | ✅ 不调接口 | +| **视觉延迟** | 极短“假连接”(你要求 0.5s) | `DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { finalizeSync() }`(0.6s) | ✅ 实现合理;若需严格 0.5s 可将 `0.6` 改为 `0.5` | +| **结果与持久化** | 直接成功并写 Key | `finalizeSync()` 内 `isSystemOn = true`、`UserDefaults.set(true, forKey: storageKey)`、触觉反馈 | ✅ 一致 | + +游客路径:点击 → `isBooting = true` → 0.6s 后主线程执行 `finalizeSync()` → 显影 + 写 Key + 成功反馈,无任何网络请求。 + +--- + +## 3. 登录用户路径 ✅ + +- `Task.sleep(1.2s)` → `fetchUserProfile()`(catch 忽略)→ `MainActor.run { finalizeSync() }`。 +- 与现版“拉取后显影”一致,无变更。 + +--- + +## 4. 结构与线程安全 ✅ + +| 项目 | 说明 | +|------|------| +| **finalizeSync()** | 集中处理显影 + 写 Key + `isBooting = false` + 触觉,避免重复;仅从主线程/主队列调用(`DispatchQueue.main.asyncAfter` 与 `MainActor.run`),对 `@State` 的更新安全。 | +| **SpeakerGrill(rotation:)** | 抽取为 `private func SpeakerGrill(rotation: Double) -> some View`,在 body 中调用合法,无问题。 | + +--- + +## 5. 可选小修正(非必须) + +- **延迟时长**:需求写“0.5s 假连接”,代码为 0.6s;若需严格一致,可将 `deadline: .now() + 0.6` 改为 `0.5`。 +- **ForEach**:若当前 Swift/SwiftUI 版本对 `ForEach(0..?` | 与提案一致(3 个参数,同名同类型) | ✅ 兼容 | +| **VerticalScreenPlayerView 调用** | `CompletionView(courseId:, courseTitle:, navigationPath:)` | 无需修改 | ✅ 可直接编译 | +| **GrowthView / 其他 navigationDestination** | 若存在 `.completion` 且传上述 3 参数 | 无需修改 | ✅ 无影响 | + +替换为 Y2K 版后,仅需整文件替换 `CompletionView.swift`,**无需改任何调用处**。 + +--- + +## 2. 数据与依赖一致性 + +| 项目 | 当前实现 | Y2K 提案 | 结论 | +|------|----------|----------|------| +| **UserManager** | `UserManager.shared`,`studyStats.(time, lessons)` | 相同 | ✅ | +| **导航** | `navStore.switchToGrowthTab()` + `navigationPath?.wrappedValue = NavigationPath()` 或 `dismiss()` | 与提案一致 | ✅ | +| **持久化 key** | `has_revealed_course_\(courseId)` | `has_y2k_booted_\(courseId)` | ⚠️ 见下 | + +- **持久化 key 不同**:若从当前「赛博拍立得」版直接切到 Y2K 版,用户在本课程下会视为「未启动过」Y2K,需再按一次 SYNC 才会显示结果。若希望换肤后「已显影过的课程仍为已显影」,可把 Y2K 的 `storageKey` 改为与现版一致(例如继续用 `has_revealed_course_\(courseId)`),否则保留 `has_y2k_booted_\(courseId)` 亦可,属产品选择。 + +--- + +## 3. 逻辑差异(游客与加载) + +| 项目 | 当前实现 | Y2K 提案 | 建议 | +|------|----------|----------|------| +| **游客 (isGuest)** | 点击后不调 API,直接显影并写持久化 | 仍执行 `Task.sleep` + `fetchUserProfile()`,catch 后置 `isSystemOn = true` | 建议在 Y2K 的 `startUpload()` 内保留与现版一致的游客分支:若 `userManager.isGuest` 则直接设 `isSystemOn = true` 并写持久化,不调 `fetchUserProfile`,避免无谓请求与约 1.2s 延迟。 | +| **登录用户** | 拉取 `fetchUserProfile` 后显影 | 同(1.2s 后拉取 + 显影) | ✅ 行为一致 | + +若严格遵循「只换皮肤、逻辑不变」,建议在 Y2K 版中补上与现版相同的 `isGuest` 短路逻辑。 + +--- + +## 4. UI / 实现细节核对 + +- **Y2K 外壳与屏幕**:半透明渐变 + `strokeBorder` 高光 + `.ultraThinMaterial`、LCD 开/关色、果冻按钮与 SYNC DATA/进度态,均为纯视觉,无逻辑影响。 +- **Y2KIdleView / Y2KResultView**:使用 `userManager.studyStats.lessons` / `time` 与当前数据源一致;字体与 XP 风格进度条仅为样式。 +- **ForEach(0..` 配合 `id: \.self` 在 SwiftUI 中合法,无需改。 +- **屏幕内布局**:内屏 `overlay` 中再 `.padding(12)` 会形成双层内边距;若希望与现版内边距一致,可后续微调数值,非阻塞。 + +--- + +## 5. 影响范围(仅换 CompletionView 时) + +- **仅替换** `Views/CompletionView.swift` 为 Y2K 版时: + - **VerticalScreenPlayerView**:无需改动。 + - **MapView / MainTabView / GrowthView / ProfileView / DiscoveryView**:无改动、无影响。 +- 若采纳「持久化 key 统一」或「游客短路」建议,仅需在 Y2K 版单文件内修改,不涉及其他页面。 + +--- + +## 6. 总结 + +| 维度 | 结论 | +|------|------| +| **接口兼容** | ✅ 三参数一致,调用方无需修改 | +| **数据与导航** | ✅ 与现版一致 | +| **持久化 key** | ⚠️ 与现版不同,换肤后需再按一次 SYNC(可按需统一 key) | +| **游客逻辑** | ⚠️ 建议在 Y2K 版中保留与现版相同的 isGuest 短路,实现「只换皮肤」 | +| **其他页面** | ✅ 无影响 | + +**审查结论**:Y2K 版可作为「仅换皮肤」的替换方案;建议在应用前(若采纳)补上游客短路逻辑,并按产品需求决定是否统一持久化 key。**本次未对仓库做任何修改。** diff --git a/ios/WildGrowth/COMPLETION_git_1.30dazhi合并前_变更清单.md b/ios/WildGrowth/COMPLETION_git_1.30dazhi合并前_变更清单.md new file mode 100644 index 0000000..a556214 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_git_1.30dazhi合并前_变更清单.md @@ -0,0 +1,136 @@ +# 1.30dazhi合并前 · 完结页相关变更清单(禁止应用) + +**说明**:根据 git 拉取的 **1.30dazhi合并前** 分支与 commit **f725f31** 核对:除完结页外,**其他页面是否有被影响**。 +**结论**:仅做审查与清单整理,**绝对禁止应用任何代码**。 + +--- + +## 一、当前分支与提交 + +- **当前分支**:`1.30dazhi合并前` +- **完结页相关提交**:`f725f31` — `feat: 完成页逻辑与占位页进入、无完成按钮;课程完成导航与文档` +- **工作区**:有 **1 个未提交修改** — `ios/WildGrowth/WildGrowth/VerticalScreenPlayerView.swift`(约 41 行变更) + +--- + +## 二、f725f31 中「除完结页外」被改动的文件(其他页面) + +在 **f725f31** 里,**除了** 新增的 `CompletionView.swift` 和文档、以及 `VerticalScreenPlayerView.swift` 的完成页逻辑外,**以下页面/文件也被一起改动了**: + +### 1. CourseNavigation.swift + +| 变更 | 说明 | +|------|------| +| `.player` | 由 `(courseId, nodeId)` 改为 `(courseId, nodeId, isLastNode, courseTitle)` | +| 新增 `.completion` | `(courseId, courseTitle, completedLessonCount)` | +| 注释 | 「从 HomeView 提取」改为「供 DiscoveryView / GrowthView / MapView / ProfileView 共用」 | + +**影响**:所有使用 `CourseNavigation.player` / `.completion` 的调用方必须传新参数或处理新 case。 + +--- + +### 2. MainTabView.swift + +| 变更 | 说明 | +|------|------| +| Tab 选中状态 | 由 `@State private var selection` 改为使用 `navStore.selectedTab` | +| 新增 `NavigationStore` | `@Published var selectedTab`、`func switchToGrowthTab()` | +| TabView 绑定 | `TabView(selection: $selection)` → `TabView(selection: $navStore.selectedTab)` | +| 登录成功跳转 | `selection = 2` → `navStore.selectedTab = 2` | +| 多处 print | `selection` → `navStore.selectedTab` | + +**影响**:Tab 切换与完成页「回到技能页」依赖 `navStore.selectedTab` 与 `switchToGrowthTab()`。 + +--- + +### 3. MapView.swift + +| 变更 | 说明 | +|------|------| +| 点击小节进入播放器 | `CourseNavigation.player(courseId, nodeId)` → `CourseNavigation.player(courseId, nodeId, isLastNode, courseTitle)` | +| 新增 `isLastNode` | `(chapter.id == data.chapters.last?.id) && (index == chapter.nodes.count - 1)` | +| 传入 `courseTitle` | `data.courseTitle` | + +**影响**:从地图进播放器时,会多传 `isLastNode`、`courseTitle`,与 `CourseNavigation` 新签名一致。 + +--- + +### 4. ProfileView.swift + +| 变更 | 说明 | +|------|------| +| `.map` | `MapView(courseId:)` → `MapView(courseId:, navigationPath: $navStore.profilePath)` | +| `.player` | `VerticalScreenPlayerView` 增加 `navigationPath: $navStore.profilePath`、`isLastNode`、`courseTitle` | +| 新增 `.completion` | `CompletionView(courseId, courseTitle, completedLessonCount)` | + +**影响**:我的 Tab 下地图、播放器、完成页的导航与传参全部按新枚举和完成页流程改过。 + +--- + +### 5. DiscoveryView.swift + +| 变更 | 说明 | +|------|------| +| `.map` | `MapView(courseId:)` → `MapView(courseId:, navigationPath: $navStore.homePath)` | +| `.player` | `VerticalScreenPlayerView` 增加 `isLastNode`、`courseTitle`(原已有 navigationPath) | +| 新增 `.completion` | `CompletionView(courseId, courseTitle, completedLessonCount)` | + +**影响**:发现 Tab 下地图、播放器、完成页的导航与传参全部按新枚举和完成页流程改过。 + +--- + +### 6. GrowthView.swift + +| 变更 | 说明 | +|------|------| +| `.player` | `VerticalScreenPlayerView` 增加 `isLastNode`、`courseTitle`(原已有 MapView navigationPath) | +| 新增 `.completion` | `CompletionView(courseId, courseTitle, completedLessonCount)` | + +**影响**:技能 Tab 下播放器、完成页的导航与传参全部按新枚举和完成页流程改过。 + +--- + +### 7. VerticalScreenPlayerView.swift(f725f31 内) + +| 变更 | 说明 | +|------|------| +| 占位页 | `CompletionPlaceholderPage` 由「带 courseId/courseTitle/navigationPath + onAppear 里 push」改为「纯视觉占位」 | +| 新增 | TabView 上 `.onChange(of: currentNodeId)`,当 `newId == "wg://completion"` 时 0.1s 后 append `.completion` | + +**影响**:完成页进入方式从「占位页 onAppear」改为「父视图 onChange」,避免预加载误触。 + +--- + +## 三、当前工作区未提交改动(仅 1 个文件) + +| 文件 | 变更概要 | +|------|----------| +| **VerticalScreenPlayerView.swift** | 在 f725f31 基础上:CompletionPlaceholderPage 去掉参数、改为纯 ZStack;TabView 增加 `.onChange(of: currentNodeId)` 的完成页 push 逻辑(与上面「f725f31 内」描述一致,可能是同一逻辑的又一次提交前微调或重复修改) | + +**其他页面**:当前 **无** 未提交修改。GrowthView、ProfileView、DiscoveryView、MapView、MainTabView、CourseNavigation 等在工作区均为已提交状态(f725f31)。 + +--- + +## 四、直接回答「除了完结页,其他页面有没有被影响」 + +**有。** + +在引入完结页的 **f725f31** 中,除了: + +- 新增:`CompletionView.swift`、`COMPLETION_PAGE_MEANING.md`、`COMPLETION_VIEW_*` 等文档 +- 修改:`VerticalScreenPlayerView.swift`(占位页 + 完成页进入逻辑) + +还**一并修改了**以下「其他页面」以支持完成页导航与播放器参数: + +1. **CourseNavigation.swift** — 枚举增加 `.completion`、`.player` 增加 `isLastNode`、`courseTitle` +2. **MainTabView.swift** — Tab 状态迁到 `navStore.selectedTab`,新增 `switchToGrowthTab()` +3. **MapView.swift** — 进播放器时传 `isLastNode`、`courseTitle` +4. **ProfileView.swift** — MapView 传 navigationPath;播放器传 isLastNode、courseTitle;增加 `.completion` destination +5. **DiscoveryView.swift** — MapView 传 navigationPath;播放器传 isLastNode、courseTitle;增加 `.completion` destination +6. **GrowthView.swift** — 播放器传 isLastNode、courseTitle;增加 `.completion` destination + +因此:**1.30dazhi合并前** 分支上,完结页不是「只动完结页」,而是**和上述 6 个文件一起**在 f725f31 里改的;当前工作区里,只有 **VerticalScreenPlayerView.swift** 还有未提交修改,其他页面没有新的未提交变更。 + +--- + +**未对仓库进行任何应用或修改操作。** diff --git a/ios/WildGrowth/COMPLETION_iOS原生风格_定稿审查报告.md b/ios/WildGrowth/COMPLETION_iOS原生风格_定稿审查报告.md new file mode 100644 index 0000000..9bc224f --- /dev/null +++ b/ios/WildGrowth/COMPLETION_iOS原生风格_定稿审查报告.md @@ -0,0 +1,107 @@ +# CompletionView iOS 原生风格版 — 定稿审查报告(不应用) + +**审查日期**:2025-01-29 +**范围**:Final iOS Native Style(systemGroupedBackground + 白卡片 + System Blue + Widget 布局) +**结论**:仅审查、不修改仓库;需求落实正确,**有一处必须修复**(ScaleButtonStyle 重复定义)。 + +--- + +## 1. 需求落实情况 + +### 视觉风格 + +| 项目 | 要求 | 定稿实现 | 结论 | +|------|------|----------|------| +| **背景** | systemGroupedBackground 浅灰 | `Color(UIColor.systemGroupedBackground)` | ✅ | +| **卡片** | 纯白圆角 + 柔和投影 | `cardBg = .white`,`.cornerRadius(16)`,`shadow(radius:15, y:6)` | ✅ | +| **配色** | System Blue(品牌蓝) | `brandBlue = Color.blue` | ✅ | +| **布局** | Widget 小组件式 | 卡片头(学习统计 + chart 图标)+ 主视觉(小节数)+ 次视觉(专注时长) | ✅ | +| **主/次视觉** | 超大小节数 + 底部专注时长 | 80pt 数字 + 底部一行「专注时长 X min」 | ✅ | + +### 交互与动效 + +| 项目 | 要求 | 定稿实现 | 结论 | +|------|------|----------|------| +| **按钮文案** | 上传数据 → Loading → 已同步 | 三态:`Text("上传数据")` / `ProgressView` / `HStack(checkmark + "已同步")` | ✅ | +| **按钮颜色** | 严禁变绿,始终蓝底 | `background(brandBlue)` | ✅ | +| **屏幕动效** | 数据加载完成,内容淡入 | `withAnimation(.easeOut(0.3)) { isSystemOn = true }` | ✅ | +| **数字动效** | 从 0 匀速滚动到目标,1.5s | `withAnimation(.linear(1.5)) { displayLessons/Minutes }` + `RollingNumberText` | ✅ | + +### 业务逻辑 + +| 项目 | 要求 | 定稿实现 | 结论 | +|------|------|----------|------| +| **数据源** | UserManager.shared | `@ObservedObject userManager = UserManager.shared` | ✅ | +| **持久化** | UserDefaults 防重复动效 | `storageKey: "has_revealed_course_\(courseId)"` | ✅ | +| **导航** | `navStore.switchToGrowthTab()` | 一致 | ✅ | + +--- + +## 2. 逻辑继承(FRC 要求) + +| 项目 | 结论 | +|------|------| +| **持久化 Key** | ✅ `has_revealed_course_\(courseId)` | +| **游客短路** | ✅ 0.5s 延迟,`performActivation(0, 0)`,不调网络 | +| **登录用户** | ✅ 0.8s 延迟 → fetchUserProfile → performActivation | +| **checkSystemStatus** | ✅ 已激活时恢复最终值,无动画 | + +--- + +## 3. ⚠️ 必须修复:ScaleButtonStyle 重复定义 + +**问题**:定稿代码在文件末尾定义了 `struct ScaleButtonStyle: ButtonStyle`,而项目中已在 `NotebookListView.swift`(约第 287 行)定义同名 struct。同一 target 内存在两个 `ScaleButtonStyle` 会导致 **invalid redeclaration** 编译错误。 + +**修复**:删除 CompletionView.swift 中 `ScaleButtonStyle` 的完整定义(`struct ScaleButtonStyle: ButtonStyle { ... }`),直接使用项目已有的实现。`ScaleButtonStyle` 在同一模块内可访问,无需额外导入。 + +--- + +## 4. RollingNumberText + +- `RollingNumberText` 仅在 CompletionView 相关代码中使用,当前实现 `Animatable` 正确,与定稿逻辑一致。 +- 保留在 CompletionView.swift 内不会产生符号冲突。 + +--- + +## 5. 模块化与 HIG 符合度 + +- 将 `SummaryCardView`、`ActiveDashboardView`、`IdlePlaceholderView`、`UploadButton` 拆分为 computed property,结构清晰。 +- 使用 `Color(UIColor.systemGroupedBackground)`、`Color(UIColor.secondarySystemGroupedBackground)` 等系统颜色,符合 iOS HIG。 +- `ScaleButtonStyle` 的按压缩放效果与 NotebookListView 中的实现一致,视觉体验统一。 + +--- + +## 6. 接口与影响范围 + +| 项目 | 结论 | +|------|------| +| **初始化参数** | 三参数不变 | +| **替换方式** | 整文件替换 `CompletionView.swift` | +| **其他页面** | 无影响 | + +--- + +## 7. 总结 + +| 维度 | 结论 | +|------|------| +| **需求落实** | ✅ 视觉、交互、动效、业务逻辑均符合要求 | +| **逻辑继承** | ✅ Key、游客、登录用户逻辑正确 | +| **ScaleButtonStyle** | ❌ **必须删除定稿中的定义**,改用项目已有实现 | +| **接口兼容** | ✅ 可直接替换 | + +**审查结论**:定稿在逻辑和需求上正确,应用前需**删除 CompletionView.swift 中的 `struct ScaleButtonStyle` 定义**,否则无法通过编译。**本次未对仓库做任何修改。** + +--- + +## 8. 修正版审查确认(2025-01-29 续) + +**修正内容**:已移除 `ScaleButtonStyle` 结构体定义,仅保留 `.buttonStyle(ScaleButtonStyle())` 调用,复用 `NotebookListView.swift` 中已有实现。 + +| 项目 | 修正前 | 修正后 | 结论 | +|------|--------|--------|------| +| **ScaleButtonStyle** | 文件末尾重复定义 | 已删除,使用项目已有 | ✅ 修复完成 | +| **调用处** | `.buttonStyle(ScaleButtonStyle())` | 不变,解析为 NotebookListView 中定义 | ✅ 正确 | +| **注释** | — | 添加「使用项目已有」「此处不重复声明」 | ✅ 便于后续维护 | + +**结论**:修正版可**直接替换** `CompletionView.swift`,无编译冲突。**本次未对仓库做任何修改。** diff --git a/ios/WildGrowth/COMPLETION_实现后行为与影响说明.md b/ios/WildGrowth/COMPLETION_实现后行为与影响说明.md new file mode 100644 index 0000000..684f40b --- /dev/null +++ b/ios/WildGrowth/COMPLETION_实现后行为与影响说明.md @@ -0,0 +1,129 @@ +# 完成页导航方案 — 实现后行为与影响说明 + +说明:实现方案后**会变成什么样**、**会不会影响原有逻辑和展示**、**会不会导致其他地方出问题**。不修改代码,仅作说明。 + +--- + +## 一、实现后会变成什么样 + +### 1. 进入完成页(两种方式,二选一或并存) + +| 方式 | 当前 | 实现后(若保留占位页 + 加手势) | +|------|------|----------------------------------| +| **左滑到「下一页」** | 最后一节再左滑 → 进入占位页(空白)→ 0.1s 后 push 完成页 | 不变:仍可滑到占位页,再由 `onChange` push | +| **在最后一节左滑** | 无 | 新增:在最后一节直接左滑(手势识别)→ 可设为「切到占位页再 push」或「直接 push 完成页」 | + +若方案采用「删除占位页、仅手势」: +- 最后一节左滑 → 直接 push 完成页,**不再出现空白占位页那一屏**。 +- TabView 只有真实课程页,不再有「多一页」的占位。 + +**展示上**:完成页本身 UI 不变(仍是当前 CompletionView 的 3D 卡片、打字机等);变的只是「怎么进」和「怎么回」。 + +--- + +### 2. 从完成页返回(核心变化) + +| 操作 | 当前 | 实现后 | +|------|------|--------| +| **顶部返回 (chevron)** | `dismiss()` → 回到播放器(最后一节或占位页) | 可保持不变:仍 `dismiss()`,回到播放器 | +| **底部「继续学习」** | `navStore.switchToGrowthTab()` + `dismiss()` | `handleReturnToMap()`:`navigationPath.removeLast(2)` | + +**「继续学习」行为对比**: + +- **当前**: + 1. `dismiss()` → 栈 pop 一层,回到 **VerticalScreenPlayerView**(当前 Tab 可能是占位页,`onAppear` 里会切回最后一节)。 + 2. `switchToGrowthTab()` → 切到「技能」Tab,且 **清空 `growthPath`**(`growthPath = NavigationPath()`)。 + 3. 用户最终看到的是 **技能 Tab 的根界面(课程列表)**,不是地图。 + +- **实现后**: + 1. `removeLast(2)` → 一次 pop 掉「完成页」和「播放器」。 + 2. 栈变成:**[MapView]**(或更短,视当前 path 而定)。 + 3. **不**调用 `switchToGrowthTab()`,所以**不会清空 path**,也**不一定会切 Tab**。 + 4. 用户最终看到的是 **当前 Tab 下的地图页**(从哪个 Tab 进的,就还在哪个 Tab)。 + +**总结**: +- 实现后,点「继续学习」会**直接回到地图**,且**保留在当前 Tab**(发现 / 技能 / 我的)。 +- 当前是**回到课程列表**(且强制在技能 Tab)。这是**产品行为上的明确变化**。 + +--- + +### 3. 三条入口分别会怎样 + +| 入口 | 当前栈(到完成页时) | 当前「继续学习」后 | 实现后「继续学习」后 | +|------|----------------------|--------------------|------------------------| +| **技能 Tab** | growthPath: [Map, Player, Completion] | 切到技能 Tab + 清空栈 → **课程列表** | 仍技能 Tab,栈 [Map] → **地图** | +| **发现 Tab** | homePath: [Map, Player, Completion] | dismiss 回播放器;switchToGrowthTab 切到技能并清空 → **课程列表** | 仍发现 Tab,栈 [Map] → **地图** | +| **我的 Tab** | profilePath: [Map, Player, Completion] | 同上 → **课程列表** | 仍我的 Tab,栈 [Map] → **地图** | + +--- + +## 二、会不会影响原有逻辑和展示 + +### 1. 会改变的部分(有意为之) + +- **底部按钮语义**:「继续学习」从「回课程列表(并切到技能)」变为「回地图(且留在当前 Tab)」。 +- **是否清空栈**:不再在完成页里清空 `growthPath`,所以不会出现「从完成页一点就回到空白课程列表」。 +- **是否切 Tab**:不再强制切到技能 Tab;用户会留在发现 / 技能 / 我的中的当前 Tab。 + +若产品期望就是「完成课后回到地图、留在当前 Tab」,则与方案一致;若期望是「回到课程列表、且一定在技能 Tab」,则与**当前**一致,与**实现后**不一致,需要产品确认。 + +### 2. 可保持不变的部分 + +- **完成页 UI**:3D 翻转卡片、打字机、顶部返回、按钮样式等都可以不改。 +- **顶部返回**:继续用 `dismiss()`,仍回到播放器,逻辑和展示都不变。 +- **进入完成页的方式**:若保留占位页,现有「滑到占位页再 push」仍可用;只是多了一种「最后一节左滑」的触发方式(若加手势)。 + +### 3. 依赖「当前行为」的地方 + +- **NavigationStore.switchToGrowthTab()**:当前只在 CompletionView 的「继续学习」里被调用。实现后 CompletionView 不再调用它,**不会影响其他使用处**(因为别处没有用这个方法)。 +- **growthPath 被清空**:当前只有「完成页点继续学习」会清空 growthPath。若没有「从完成页返回后依赖 growthPath 为空」的逻辑,则实现后**不会破坏其它功能**。 + +--- + +## 三、会不会导致其他地方出问题 + +### 1. 三个 Tab 的 navigationDestination(必须改) + +- **GrowthView / ProfileView / DiscoveryView** 里 `.completion` 分支都要给 CompletionView 传 **Binding**: + `navigationPath: $navStore.growthPath` / `$navStore.profilePath` / `$navStore.homePath`。 +- **漏传或错传**: + - 漏传 → 编译不通过(CompletionView 多了必选参数)。 + - 错传(例如发现进的完成页却传了 `growthPath`)→ 点「继续学习」会 pop 错栈,可能白屏或回到错误 Tab。 +- **结论**:三处都必须改,且必须传**当前栈对应的 path**,否则会出问题。 + +### 2. 笔记流(NoteTreeView / NoteListView)— 不受影响 + +- 笔记流用的是 **NoteNavigationDestination.player**,打开的是 **VerticalScreenPlayerView**,且**没有传 navigationPath**(或传的是笔记自己的 path)。 +- 在这些地方打开的播放器里,`navigationPath` 为 nil,现有逻辑里 `onChange` 里会 `guard let path = navigationPath else { return }`,**不会 push CourseNavigation.completion**。 +- 因此:从笔记进的播放器,**不会**出现「滑到完成页」的 push(除非你后续在笔记流里也接 Course 的 completion)。 +- **结论**:实现完成页方案**不会影响笔记流**,也不会从笔记流误进完成页。 + +### 3. 从完成页返回时栈深度不足 + +- 正常流程栈至少为 [Map, Player, Completion],`removeLast(2)` 安全。 +- 若将来有「深链、通知、分享」等直接打开 CompletionView,栈可能只有 [Completion],此时 `path.count >= 2` 为假,应走兜底 `dismiss()`。 +- 方案里已建议保留 `if navigationPath.count >= 2 { removeLast(2) } else { dismiss() }`,**不会因为栈浅而崩溃**,最多退回单层 pop。 + +### 4. 手势与 TabView 滑动 + +- 在最后一节加「左滑进完成页」时,与 TabView 自带的左滑翻页**共用同一方向**,可能冲突(例如:想翻页却触发了完成页,或想进完成页却只翻到占位页)。 +- 若用「边缘左滑」或阈值(如 -60pt)、防抖,可减轻误触;需在真机多测。 +- **结论**:可能带来**体验上的小问题**(误触/难触),不是逻辑错误,可通过手势参数和测试收敛。 + +### 5. 其他使用 VerticalScreenPlayerView / CompletionView 的地方 + +- **VerticalScreenPlayerView**:除 GrowthView / ProfileView / DiscoveryView 外,仅在 **NoteTreeView / NoteListView** 出现,且走的是 NoteNavigationDestination,不涉及 CourseNavigation.completion,**不受影响**。 +- **CompletionView**:只在这三个 Tab 的 `navigationDestination(for: CourseNavigation.self)` 的 `.completion` 分支出现,**没有其它调用点**。 +- **结论**:只要三处传参正确,**不会导致其它页面逻辑错误**。 + +--- + +## 四、简要结论表 + +| 问题 | 结论 | +|------|------| +| 实现后会变成什么样? | 进入完成页可增加「最后一节左滑」触发;「继续学习」从「回课程列表 + 切技能 Tab」变为「直接回地图 + 保留当前 Tab」;可选去掉占位页。 | +| 会不会影响原有逻辑和展示? | 会:底部按钮语义和最终停留页(地图 vs 课程列表)、是否清栈/切 Tab 会变;顶部返回和完成页 UI 可保持不变。 | +| 会不会导致其他地方出问题? | 三处传 Binding 必须正确,否则会 pop 错栈;笔记流、其它使用点不受影响;栈浅时用 dismiss 兜底;手势可能与 TabView 滑动冲突,需真机调参。 | + +**建议**:实现前与产品确认「继续学习」的预期是「回地图」还是「回课程列表」;若确认为回地图且保留当前 Tab,再按方案改并保证三处 path 传参一致。 diff --git a/ios/WildGrowth/COMPLETION_强力侧滑版_审查报告.md b/ios/WildGrowth/COMPLETION_强力侧滑版_审查报告.md new file mode 100644 index 0000000..5eb12e0 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_强力侧滑版_审查报告.md @@ -0,0 +1,82 @@ +# 完成页「强力侧滑版」— 审查报告(禁止应用) + +**审查对象**:粉紫勋章视觉 + RobustSwipeBackEnabler(didMove + viewDidAppear 双节点强制开启侧滑) +**结论**:仅审查,不修改仓库内任何文件。 + +--- + +## 一、问题与方案对应 + +| 问题 | 方案 | 审查结论 | +|------|------|----------| +| 隐藏导航栏后侧滑返回失效 | 用 `UIViewController` 子类在 `didMove(toParent:)` 与 `viewDidAppear` 两处调用 `enableGesture()` | ✅ 思路正确,双节点可提高找回手势的概率 | +| 之前 SwipeBackEnabler 时机不对或被覆盖 | 使用自定义 `SwipeBackController` 继承 `UIViewController`,在生命周期中多次执行 | ✅ 比仅用 `updateUIViewController` 的 Representable 更稳 | + +--- + +## 二、强力侧滑实现审查 + +### 2.1 RobustSwipeBackEnabler + SwipeBackController + +- **Representable**:`makeUIViewController` 返回 `SwipeBackController()`,`updateUIViewController` 为空,符合「只挂一个控制器做生命周期」的用法。 +- **SwipeBackController**: + - `didMove(toParent:)`:视图被加入/移出父控制器时调用,此时通常已进入导航栈,可拿到 `navigationController`。 + - `viewDidAppear`:每次该页显示时再执行一次,可应对 SwiftUI 更新或系统把手势关掉的情况。 +- **enableGesture()**: + `nc.interactivePopGestureRecognizer?.delegate = nil` + `isEnabled = true` 是常见做法,用于在隐藏导航栏时恢复全屏侧滑。逻辑正确。 + +### 2.2 潜在注意点(非否决项) + +1. **视图层级**:`.background(RobustSwipeBackEnabler())` 会把 `SwipeBackController` 的 view 作为 CompletionView 的底层。SwiftUI 的 NavigationStack 对应底层会有一个 `UINavigationController`,`self.navigationController` 应是该栈,因此一般能正确找到。若将来把 CompletionView 放在非 NavigationStack 的容器里,需再确认 `navigationController` 是否仍为预期栈。 +2. **多次执行**:`didMove` / `viewDidAppear` 可能被多次调用(例如 SwiftUI 重绘、present 方式变化),重复执行 `enableGesture()` 无副作用,可接受。 +3. **真机验证**:建议在真机上从「发现 / 技能 / 我的」三个 Tab 分别进入完成页,各做几次右滑返回,确认无偶发失效;若仍有失效,可再在 `viewWillAppear` 或短延迟 `DispatchQueue.main.async` 中补一次 `enableGesture()`。 + +--- + +## 三、视觉与交互审查 + +### 3.1 与需求对照 + +| 需求 | 实现 | 结论 | +|------|------|------| +| 粉紫勋章:点前灰色+「完成」,点后粉紫+「共完成 XX 节」 | 未激活:灰底 + 灰边 + 「完成」;激活:粉紫渐变圆 + 白字「共完成 / N 小节」+ 冲击波 | ✅ 一致 | +| 顶部无返回按钮,只有标题「已完成」 | `ZStack` 中仅 `Text("已完成")`,无 Button | ✅ 一致 | +| 底部淡蓝按钮、无箭头 | `Text("回到我的内容")`,`.foregroundColor(.brandVital)`,`Capsule().fill(Color.brandVital.opacity(0.08))`,无 SF Symbol | ✅ 一致 | +| 右滑回最后一节 | 依赖 RobustSwipeBackEnabler | ✅ 见上 | +| 底部按钮回技能页根 | `handleReturnToRoot()` → `navStore.switchToGrowthTab()` | ✅ 一致 | + +### 3.2 配色与 DesignSystem + +- 方案中本地定义: + - `cyberPink = Color(red: 1.0, green: 0.25, blue: 0.50)` → 等价 `#FF4081` + - `cyberPurple = Color(red: 0.54, green: 0.31, blue: 1.0)` → 等价 `#8A4FFF` +- **DesignSystem** 中已有: + - `Color.cyberNeon` = `#FF4081` + - `Color.cyberIris` = `#8A4FFF` +- **建议**(可选):将勋章渐变的 `cyberPink` / `cyberPurple` 改为 `Color.cyberNeon` / `Color.cyberIris`,避免重复定义并统一设计系统;若你希望完成页与 DesignSystem 解耦则可保留当前写法。 + +### 3.3 其他细节 + +- 冲击波:`rippleScale` 从 1.0 到 1.8、`rippleOpacity` 从 1.0 到 0,时长 0.8s,视觉合理。 +- 震动:`UIImpactFeedbackGenerator(style: .heavy)`,与「点亮」动作匹配。 +- 底部按钮常驻显示(不依赖 `isActive`),与「回到我的内容」随时可点一致。 + +--- + +## 四、接口与调用方 + +- **CompletionView** 仍为三参:`courseId`, `courseTitle`, `completedLessonCount`,依赖 `@EnvironmentObject navStore`。 +- **GrowthView / ProfileView / DiscoveryView** 无需改构造或传参。 + +--- + +## 五、审查结论汇总 + +| 项 | 结论 | +|----|------| +| 强力侧滑方案 | 双生命周期节点(didMove + viewDidAppear)合理,实现正确;建议真机多入口验证,必要时可再加 viewWillAppear 或短延迟兜底。 | +| 粉紫勋章视觉 | 与描述一致;可选改用 DesignSystem 的 cyberNeon/cyberIris 统一色值。 | +| 顶部 / 底部 / 交互 | 符合「无返回、仅标题、淡蓝按钮无箭头、底部回技能页根」的要求。 | +| 全量替换范围 | 仅替换 `CompletionView.swift` 即可;VerticalScreenPlayerView 等不动。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_新中式赛博版_审查报告.md b/ios/WildGrowth/COMPLETION_新中式赛博版_审查报告.md new file mode 100644 index 0000000..9e95097 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_新中式赛博版_审查报告.md @@ -0,0 +1,100 @@ +# CompletionView「新中式赛博」版设计方案 — 审查报告(不应用) + +## 一、结论摘要 + +- **接口**:与现有调用方完全兼容,无需修改 `VerticalScreenPlayerView` 等。 +- **逻辑**:导航与“回到我的内容”行为与现有一致;仅交互由「点击勋章激活」改为「点击卡片 3D 翻转」。 +- **建议**:设计说明中的「微小的上箭头」与代码中的 `sparkles` 不一致,若需严格按文案可改为 `arrow.up`;其余可合并。 + +--- + +## 二、接口与调用方 + +| 项目 | 当前 CompletionView | 提案代码 | 结论 | +|------|---------------------|----------|------| +| 入参 | `courseId`, `courseTitle?`, `completedLessonCount`, `navigationPath?` | 同左 | ✅ 一致 | +| 调用处 | `VerticalScreenPlayerView` 传入上述 4 项 | 无需改动 | ✅ 兼容 | + +调用处代码保持不变即可: + +```swift +CompletionView( + courseId: courseId, + courseTitle: self.courseTitle ?? mapData?.courseTitle, + completedLessonCount: UserManager.shared.studyStats.lessons, + navigationPath: navigationPath +) +``` + +--- + +## 三、行为与逻辑 + +| 能力 | 当前实现 | 提案实现 | 结论 | +|------|----------|----------|------| +| 清空栈 / 返回 | `handlePopToRoot()`:有 `navigationPath` 则 `path.wrappedValue = NavigationPath()`,否则 `dismiss()` | 相同 | ✅ 一致 | +| 触觉反馈 | 点击时 heavy,按钮时 light | 翻转时 medium,按钮时 light | ✅ 可接受 | +| 隐藏导航栏 | `.toolbar(.hidden, for: .navigationBar)` | 同左 | ✅ 一致 | + +交互变化仅为表现形式: + +- **当前**:点击圆形勋章 → 激活态 → 显示「共完成 X 小节」+ 底部「回到我的内容」。 +- **提案**:点击卡片 → 3D 翻转 → 背面「共完成 X 小节」+ 底部「回到我的内容」(翻转后按钮由 0.6 到 1 透明度)。 + +语义一致,无逻辑冲突。 + +--- + +## 四、视觉与实现细节 + +1. **背景** + 提案使用 `Color.bgPaper`,当前为 `Color.white`。与 app 内其他页面统一,更一致,✅ 合理。 + +2. **正面 (CardFrontView)** + - 竖排「完」「成」、180pt、serif、渐变 + 0.8 透明度、微旋转、裁切与 `drawingGroup` 均合理。 + - 底部提示:「点击开启」+ 图标。设计说明为「微小的上箭头」,代码为 `Image(systemName: "sparkles")`。若需与说明严格一致,建议改为 `arrow.up` 或同时保留「点击开启」文案。 + +3. **背面 (CardBackView)** + - 「共完成」+ 数字 +「小节」、渐变底、装饰圆环,与当前信息一致,✅。 + +4. **底部按钮** + - 文案「回到我的内容」+ 箭头不变。 + - 提案为白底胶囊 + 淡描边,未翻转时 `opacity(0.6)`,翻转后 1;当前为渐变按钮始终可见。属设计取舍,✅ 可接受。 + +5. **渐变色** + - 提案背面紫为 `Color(red: 0.58, green: 0.28, blue: 0.95)`,当前为 `0.62, 0.35, 1.0`,差异较小,✅ 无问题。 + +--- + +## 五、潜在风险与注意点 + +| 项 | 说明 | 风险 | +|----|------|------| +| `.foregroundStyle(gradient.opacity(0.8))` | `LinearGradient` 的 `.opacity(0.8)` 在 iOS 15+ 作为 `ShapeStyle` 使用无问题 | 低 | +| `rotation3DEffect` + `perspective: 0.8` | 常规用法 | 无 | +| CardFrontView 呼吸动画 | `hintOpacity` 0.3 ↔ 0.8,`repeatForever(autoreverses: true)` | 无 | +| 卡片尺寸 280×400 | 与当前 200×200 圆不同,仅布局变化 | 无 | + +未发现编译或运行时错误;替换为提案代码后,仅需在真机/模拟器上确认一次翻转与返回流程即可。 + +--- + +## 六、与设计说明的对照 + +| 设计说明 | 代码实现 | 一致性 | +|----------|----------|--------| +| 竖排超大字「完」「成」 | VStack + 180pt serif | ✅ | +| 出血/裁切感 | offset + clipShape + 圆角 | ✅ | +| 赛博粉紫渐变、降饱和度 | gradient.opacity(0.8) | ✅ | +| 底部「点击开启」 | Text("点击开启") | ✅ | +| 「微小的上箭头」 | 使用 `sparkles` 图标 | ⚠️ 不一致,可改为 `arrow.up` | +| 去掉圆圈 | 无圆圈,改为矩形卡片 | ✅ | +| 宋体 (Serif) | `.design(.serif)` | ✅ | + +--- + +## 七、审查结论 + +- **可合并**:接口、导航与核心逻辑与现有一致,无破坏性变更。 +- **可选修改**:将底部提示图标由 `sparkles` 改为 `arrow.up`,与「微小的上箭头」说明统一。 +- **不应用**:按你要求本次仅审查,不替换 `CompletionView.swift`;若后续决定采用,可直接用提案代码替换整个文件,并视需要做上述图标小改。 diff --git a/ios/WildGrowth/COMPLETION_最终交付_UI聚焦审查报告.md b/ios/WildGrowth/COMPLETION_最终交付_UI聚焦审查报告.md new file mode 100644 index 0000000..7702135 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_最终交付_UI聚焦审查报告.md @@ -0,0 +1,76 @@ +# 完成页「最终交付 · UI 聚焦」审查报告(禁止应用) + +**审查对象**:仅实现完结页 UI + 统一分页,不碰数据排序与 isFirstNodeInChapter 判定逻辑的最终交付代码。 +**结论**:仅审查,不修改仓库内任何文件。核对「仅 UI、其他不变」及与当前仓库逻辑的一致性。 + +--- + +## 一、交付方承诺的「不变」项核对 + +| 承诺 | 本版代码 | 与当前仓库对比 | 结论 | +|------|----------|----------------|------| +| **loadMapData 不全局重排** | `realNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }`,无 `.sorted` | 当前:`allCourseNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }`,无排序 | ✅ 一致 | +| **isFirstNodeInChapter 保持原有逻辑** | `chapter.nodes.filter({ $0.status != .locked }).first`,无排序 | 当前:`validNodes = chapter.nodes.filter(...).sorted { $0.order < $1.order }`,再 `validNodes.first?.id == nodeId` | ⚠️ 见下 1.1 | +| **仅统一分页 + 完结页 UI** | 数据源改为 allItems (PlayerItem),最后一页渲染 CompletionView;其余 init/错误/toast/tabBar/handleBack 等保留 | — | ✅ 符合 | + +### 1.1 isFirstNodeInChapter 与「严格保持原有逻辑」的差异 + +- **当前仓库**:在 chapter 内先取 `validNodes = chapter.nodes.filter { $0.status != .locked }.sorted { $0.order < $1.order }`,再判断 `validNodes.first?.id == nodeId`,即「**按 order 排序后的第一章第一节**」。 +- **本版交付**:`chapter.nodes.filter({ $0.status != .locked }).first`,即「**数组顺序下的第一个未锁定节点**」,无 `.sorted { $0.order < $1.order }`。 + +因此:本版 **改动了** isFirstNodeInChapter 的判定规则(从「按 order 的首节」变为「按数组顺序的首节」)。若后端或产品依赖「按 order 的首节」显示章节标题,本版可能与现有行为不一致。 + +**建议**:若需 **严格保持原有判定规则**,应在 isFirstNodeInChapter 内保留与当前一致的写法: + +```swift +if chapter.nodes.contains(where: { $0.id == nodeId }) { + let validNodes = chapter.nodes + .filter { $0.status != .locked } + .sorted { $0.order < $1.order } + return validNodes.first?.id == nodeId +} +``` + +其余(loadMapData 不排序、仅统一分页与完结页 UI)本版已满足。 + +--- + +## 二、CompletionView 审查 + +- **职责**:仅展示(粉紫勋章、共完成 N 节、底部「回到我的内容」)+ `navStore.switchToGrowthTab()`,无 navigationPath、无数据处理。✅ +- **接口**:courseId / courseTitle / completedLessonCount 三参保留,与 CourseNavigation.completion 及三处 destination 兼容。✅ +- **替换方式**:全量替换 `Views/CompletionView.swift` 即可。✅ + +**结论**:CompletionView 符合「仅完结页 UI、其他不变」。 + +--- + +## 三、VerticalScreenPlayerView 审查 + +### 3.1 已符合「仅 UI + 统一分页、其他不变」 + +- **Init**:6 参未改,外部调用零影响。✅ +- **loadMapData**:flatMap + filter,无 sort;allItems = realNodes.map(.lesson) + append(.completion)。与当前「章节顺序 + 数组顺序」一致。✅ +- **错误态 / toast / hideTabBar / showTabBar / handleBack(path 优先)**:均保留。✅ +- **LessonPageView**:传 `self.courseTitle ?? mapData?.courseTitle`、navigationPath;headerConfig 仍用 isFirstNodeInChapter / getChapterTitle。✅ +- **CompletionView(内嵌)**:courseId、courseTitle、completedLessonCount。✅ +- **currentPositionProgress**:仅按 lesson 项计算,忽略完结页。✅ +- **合并范围**:说明中已注明仅替换主视图 Struct,保留 HeaderConfig、CourseProgressNavBar、LessonPageView 等。✅ + +### 3.2 唯一需确认处:isFirstNodeInChapter + +如上 1.1:当前仓库使用 **sorted { $0.order < $1.order }** 再取 first;本版使用 **.filter().first**(无排序)。若要求「严格保持原有逻辑、不修改判定规则」,需在替换时保留当前 isFirstNodeInChapter 实现(含 .sorted { $0.order < $1.order })。 + +--- + +## 四、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **loadMapData** | 无全局排序,与当前「章节顺序 + 数组顺序」一致。✅ | +| **isFirstNodeInChapter** | 本版为「数组顺序首节点」;当前为「按 order 排序后首节点」。若需与现有行为完全一致,需保留当前的 .sorted { $0.order < $1.order } 写法。⚠️ | +| **CompletionView** | 仅 UI + 退出导航,可全量替换。✅ | +| **VerticalScreenPlayerView** | 仅替换主视图 Struct,保留同文件其余类型;其他逻辑(错误/toast/tabBar/handleBack/传参)不变。✅ | +| **其他页面 / 功能** | 接口与调用方式未改,其他页面、其他功能不受影响。✅ | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_最终交付_isFirstNode回滚_审查反馈.md b/ios/WildGrowth/COMPLETION_最终交付_isFirstNode回滚_审查反馈.md new file mode 100644 index 0000000..edc002f --- /dev/null +++ b/ios/WildGrowth/COMPLETION_最终交付_isFirstNode回滚_审查反馈.md @@ -0,0 +1,103 @@ +# 最终交付代码审查反馈(isFirstNodeInChapter 完全回滚版) + +**审查对象**:您提供的「CompletionView 全量替换 + VerticalScreenPlayerView 仅替换 Struct」的最终交付代码,并已恢复 `isFirstNodeInChapter` 为含 `.sorted { $0.order < $1.order }` 的写法。 +**结论**:仅反馈,**不应用、不修改**仓库内任何文件。 + +--- + +## 参数说明(白话) + +| 说法 | 指什么 | 作用 | +|------|--------|------| +| **CompletionView 仍为 3 个参数** | 构造时仍是 `(courseId, courseTitle, completedLessonCount)` | GrowthView / ProfileView / DiscoveryView 里已有 `CompletionView(courseId: ..., courseTitle: ..., completedLessonCount: ...)` 的调用不用改;替换成新 UI 后接口不变,不会报错。 | +| **VerticalScreenPlayerView init 6 参** | 构造时仍是 `(courseId, nodeId, initialScrollIndex?, navigationPath?, isLastNode?, courseTitle?)` | MapView / GrowthView / DiscoveryView / ProfileView 等传入的 6 个参数(或只传前几项、后几项用默认值)都不用改;只改播放器内部实现,调用方零改动。 | + +--- + +## 应用后仅实现以下三点、且无多余修改 + +若您**只**做这两步:① 全量替换 `CompletionView.swift`;② 仅替换 `VerticalScreenPlayerView.swift` 里的 **struct VerticalScreenPlayerView**(保留同文件内 HeaderConfig、LessonPageView 等其余代码),则: + +| # | 需求 | 是否由本交付代码实现 | 说明 | +|---|------|----------------------|------| +| 1 | 播放器最后一个小节左滑进入完结页,从完结页右滑回到最后一个小节页 | ✅ 是 | 完结页作为 TabView 的最后一页内嵌在播放器内,左滑最后一节→完结页,右滑完结页→最后一节,无 push/pop。 | +| 2 | 完结页的 UI,以及从接口/数据获取「共完成多少个小节」 | ✅ 是 | 新 UI(粉紫勋章、点击点亮);数量来自 `UserManager.shared.studyStats.lessons`(应用内统计,通常由学习进度接口或本地完成逻辑更新)。 | +| 3 | 底部一个按钮,点击回到技能 Tab(我的课程列表) | ✅ 是 | 按钮「回到我的内容」仅调用 `navStore.switchToGrowthTab()`,切到技能 Tab。 | + +**其他没有任何多余修改**: + +- **只动 2 个文件**:`CompletionView.swift`(全量)、`VerticalScreenPlayerView.swift`(仅主 struct)。 +- **不改动**:CourseNavigation、MainTabView、MapView、ProfileView、DiscoveryView、GrowthView、NoteTreeView、NoteListView 等所有其他页面与类型;不增删导航枚举、不改 Tab 结构、不改地图/发现/个人/技能页逻辑。 +- 完结页不再通过「占位页 + onChange push .completion」出现,而是作为播放器 TabView 最后一页展示,因此无需也不会去改各 Tab 的 `navigationDestination(for: .completion)` 的写法(它们保留不动,只是从播放器内不再 push .completion)。 + +**结论**:应用本交付代码后,行为严格限于上述 1、2、3 三点,无其他多余修改。 + +--- + +## 一、isFirstNodeInChapter:与当前仓库 100% 一致 ✅ + +| 对比项 | 当前仓库实现 | 您提供的交付代码 | 结论 | +|--------|--------------|------------------|------| +| 章节内节点 | 先找到包含 `nodeId` 的 chapter,再在该章内取 `validNodes` | 遍历每个 chapter,取 `validNodes` | 语义等价 | +| 排序 | `chapter.nodes.filter { $0.status != .locked }.sorted { $0.order < $1.order }` | `chapter.nodes.filter { $0.status != .locked }.sorted { $0.order < $1.order }` | ✅ 完全一致 | +| 首节判定 | `validNodes.first?.id == nodeId` | `validNodes.first?.id == nodeId`(在含该 node 的章内) | ✅ 完全一致 | + +**结论**:章节判定逻辑已完全回滚到与当前仓库一致的写法(含 `.sorted { $0.order < $1.order }`),不会改变现有「按 order 的章节首节」展示行为。 + +--- + +## 二、loadMapData:无全局排序,与当前一致 ✅ + +- **当前仓库**:`allCourseNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }`,无 `.sorted`。 +- **交付代码**:`realNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }`,仅 UI 层 `items = realNodes.map { .lesson($0) }` 再 `append(.completion)`,无全局排序。 + +**结论**:数据顺序与当前一致,未引入按 order 的整课排序。 + +--- + +## 三、CompletionView:仅 UI 更新,接口兼容 ✅ + +| 项目 | 说明 | +|------|------| +| 构造参数 | 仍为 `(courseId, courseTitle, completedLessonCount)`,GrowthView/ProfileView/DiscoveryView 等处已有调用无需改 | +| 依赖 | 仍使用 `@EnvironmentObject private var navStore: NavigationStore` | +| 按钮 | 「回到我的内容」仅调用 `navStore.switchToGrowthTab()`,未调用 `dismiss()` | +| 说明 | 在统一分页方案下,完结页作为 TabView 最后一页内嵌展示,无 push 栈,不调用 `dismiss()` 是正确行为 | + +**结论**:可全量替换 `CompletionView.swift`,仅 UI 从「翻牌 + 打字机」改为「粉紫勋章 + 点击点亮」,对外接口与行为符合预期。 + +--- + +## 四、VerticalScreenPlayerView:仅替换 Struct,其余保留 ✅ + +| 项目 | 结论 | +|------|------| +| **init** | 6 个构造参数完整保留(courseId, nodeId, initialScrollIndex?, navigationPath?, isLastNode?, courseTitle?),MapView/GrowthView 等调用方无需改 ✅ | +| **PlayerItem** | 枚举 `.lesson(MapNode)` / `.completion` 仅用于 UI 数据源,不参与完成数等业务逻辑 ✅ | +| **allItems** | 由 `realNodes` + 末尾 `.completion` 构成,无全局排序 ✅ | +| **currentPositionProgress** | 仅用 `lesson` 项计算,排除完结页,进度条正确 ✅ | +| **完结页展示** | 顶部进度条在 `currentNodeId == "COMPLETION_PAGE"` 时隐藏,逻辑正确 ✅ | +| **LessonPageView** | `courseTitle: self.courseTitle ?? mapData?.courseTitle` 可减少标题闪烁 ✅ | +| **错误态** | 保留加载失败 + 重试;交付代码中错误文案为 `.foregroundColor(.gray)`,当前为 `.inkSecondary`,属风格差异,可酌情统一 | +| **合并范围** | 仅替换 `struct VerticalScreenPlayerView { ... }` 及其内部的 `enum PlayerItem`;**必须保留**同文件内 `HeaderConfig`、`DuolingoProgressBar`、`CourseProgressNavBar`、`LessonPageView`、`LessonSkeletonView` 等所有其他类型,不得整文件覆盖 ✅ | + +--- + +## 五、对其他页面的影响 + +- **CourseNavigation**、**MainTabView**、**MapView**、**ProfileView**、**DiscoveryView**、**GrowthView** 等无需因本交付代码而改动。 +- 调用方仍按现有方式传入 6 参(含 `navigationPath?`、`isLastNode`、`courseTitle`);完结页由 TabView 内嵌展示,不再依赖 push `.completion` 或占位页 `onChange`。 + +--- + +## 六、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **isFirstNodeInChapter** | 已完全回滚为含 `.sorted { $0.order < $1.order }` 的写法,与当前仓库 100% 一致 ✅ | +| **loadMapData** | 无全局排序,与当前一致 ✅ | +| **CompletionView** | 仅 UI 更新,可全量替换 ✅ | +| **VerticalScreenPlayerView** | 仅替换主视图 Struct,保留同文件其余代码;逻辑与展示符合「逻辑回滚 + 统一分页」目标 ✅ | +| **其他页面** | 无需改动,零影响 ✅ | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_最终交付_逻辑回滚审查报告.md b/ios/WildGrowth/COMPLETION_最终交付_逻辑回滚审查报告.md new file mode 100644 index 0000000..76e3d15 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_最终交付_逻辑回滚审查报告.md @@ -0,0 +1,73 @@ +# 完成页「最终交付 · 逻辑回滚」审查报告(禁止应用) + +**审查对象**:isFirstNodeInChapter 已恢复为含 `.sorted { $0.order < $1.order }` 的最终交付代码;确认不影响现有逻辑与展示。 +**结论**:仅审查,不修改仓库内任何文件。 + +--- + +## 一、isFirstNodeInChapter 与当前仓库一致性 + +| 项目 | 当前仓库 | 本版交付 | 结论 | +|------|----------|----------|------| +| **排序** | `validNodes = chapter.nodes.filter(...).sorted { $0.order < $1.order }` | 同:`.filter { $0.status != .locked }.sorted { $0.order < $1.order }` | ✅ 一致 | +| **首节判定** | `validNodes.first?.id == nodeId`(在「包含 nodeId 的 chapter」内) | `validNodes.first?.id == nodeId`(遍历每章,等价语义) | ✅ 等价 | + +本版写法:对每章取 `validNodes`(filter + sort by order),若 `validNodes.first?.id == nodeId` 则返回 true。 +当前写法:先找到包含 nodeId 的 chapter,再在该章内取 validNodes(filter + sort by order),返回 `validNodes.first?.id == nodeId`。 +二者语义相同:「nodeId 是否为其所在章内按 order 排序后的第一个未锁定节点」。 +**结论**:章节判定逻辑与现有实现保持 100% 一致,未改动核心逻辑。 + +--- + +## 二、loadMapData:无全局排序 + +- **本版**:`realNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }`,无 `.sorted`。 +- **当前**:`allCourseNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }`,无排序。 + +**结论**:保持「章节顺序 + 数组顺序」,未引入任何重排,与现有逻辑一致。 + +--- + +## 三、CompletionView + +- **职责**:仅 UI(粉紫勋章、共完成 N 节、底部「回到我的内容」)+ `navStore.switchToGrowthTab()`,无业务/数据逻辑。 +- **接口**:courseId / courseTitle / completedLessonCount 三参保留,与 CourseNavigation.completion 及三处 destination 兼容。 +- **操作**:全量替换 `Views/CompletionView.swift` 即可。 + +**结论**:仅完结页 UI 更新,不影响现有逻辑与展示。 + +--- + +## 四、VerticalScreenPlayerView + +### 4.1 已核对项 + +- **Init**:6 参未改,外部调用零影响。 +- **loadMapData**:flatMap + filter,无 sort;allItems = realNodes.map(.lesson) + append(.completion)。 +- **isFirstNodeInChapter**:含 `.sorted { $0.order < $1.order }`,与当前仓库等价。 +- **getChapterTitle**:与当前一致。 +- **currentPositionProgress**:仅按 lesson 项计算,忽略完结页。 +- **handleBack / 错误态 / toast / hideTabBar / showTabBar**:均保留。 +- **LessonPageView**:传 `self.courseTitle ?? mapData?.courseTitle`、navigationPath;headerConfig 仍用 isFirstNodeInChapter / getChapterTitle。 +- **CompletionView(内嵌)**:courseId、courseTitle、completedLessonCount。 + +### 4.2 合并范围(再次强调) + +- **仅替换** `struct VerticalScreenPlayerView { ... }` 及内部的 `enum PlayerItem`。 +- **不得删除** 同文件内的 HeaderConfig、DuolingoProgressBar、CourseProgressNavBar、LessonPageView、LessonSkeletonView、LessonErrorView、CompletionPlaceholderPage(或占位相关)等其余类型。 + +**结论**:在仅替换主视图 Struct 的前提下,现有逻辑与展示不受影响。 + +--- + +## 五、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **isFirstNodeInChapter** | 已恢复为含 `.sorted { $0.order < $1.order }` 的写法,与当前仓库语义一致,未改动核心判定逻辑。 | +| **loadMapData** | 无全局排序,保持原有顺序。 | +| **CompletionView** | 仅 UI 更新,可全量替换。 | +| **VerticalScreenPlayerView** | 仅替换主视图 Struct,保留同文件其余代码;其他逻辑与展示不变。 | +| **现有逻辑与展示** | 在按上述范围替换的前提下,不会受到影响。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_最终修正版_审查报告.md b/ios/WildGrowth/COMPLETION_最终修正版_审查报告.md new file mode 100644 index 0000000..70431d3 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_最终修正版_审查报告.md @@ -0,0 +1,89 @@ +# 完成页「最终修正版」代码审查报告(禁止应用) + +**审查对象**:基于零影响审查报告修正后的 CompletionView + VerticalScreenPlayerView 最终版(参数优先、错误 Toast、排序逻辑、接口兼容)。 +**结论**:仅审查,不修改仓库内任何文件。核对三项修正与 6 条零影响条件,并说明应用时的合并范围。 + +--- + +## 一、三项修正落实情况 + +| 修正项 | 审查报告要求 | 本版代码 | 结论 | +|--------|----------------|----------|------| +| **参数优先权** | LessonPageView 传 `courseTitle ?? mapData?.courseTitle` | `courseTitle: self.courseTitle ?? mapData?.courseTitle`(LessonPageView 与 CompletionView 均用) | ✅ 已落实 | +| **错误处理** | loadMapData 失败时 `showToastMessage("加载失败")` | catch 内 `self.showToastMessage("加载失败")` | ✅ 已落实 | +| **排序逻辑** | isFirstNodeInChapter 用 `validNodes.sorted { $0.order < $1.order }` 再取 first | `validNodes = chapter.nodes.filter(...).sorted { $0.order < $1.order }`,再 `validNodes.first?.id == nodeId` | ✅ 已落实 | + +--- + +## 二、6 条零影响条件核对 + +| # | 条件 | 本版代码 | 结论 | +|---|------|----------|------| +| 1 | VerticalScreenPlayerView init 保持 6 参 | `init(courseId, nodeId, initialScrollIndex, navigationPath?, isLastNode?, courseTitle?)` 完整 | ✅ | +| 2 | 保留 CourseNavigation.completion 及三处 destination | 未改枚举与三 Tab;CompletionView 三参 | ✅ | +| 3 | CompletionView 保留三参 | courseId, courseTitle, completedLessonCount | ✅ | +| 4 | LessonPageView 传 courseTitle、navigationPath | `self.courseTitle ?? mapData?.courseTitle`,`navigationPath` 透传 | ✅ | +| 5 | 保留 loadError、toast、hideTabBar/showTabBar、handleBack(path 优先) | 错误态、toast、tabBar、handleBack 均保留;loadMapData 失败有 showToastMessage | ✅ | +| 6 | NoteTreeView / NoteListView 不修改 | init 未改,笔记流仍只传 3 参 | ✅ | + +--- + +## 三、CompletionView 审查 + +- **接口**:courseId / courseTitle / completedLessonCount 全保留,与 CourseNavigation.completion 及三处 destination 一致。✅ +- **内部**:纯 UI(粉紫勋章)+ handleReturnToRoot() → navStore.switchToGrowthTab(),无 navigationPath、无侧滑 Hack。✅ +- **视觉**:顶部「已完成」、底部「回到我的内容」淡蓝、无返回按钮。✅ + +**结论**:CompletionView 可直接全量替换 `Views/CompletionView.swift`。 + +--- + +## 四、VerticalScreenPlayerView 审查与合并范围 + +### 4.1 本版已包含且正确的部分 + +- Init 6 参、PlayerItem 枚举、allItems 数据源、loadMapData(realNodes + append .completion)、currentPositionProgress(仅 lesson)、handleBack(path 优先)、hideTabBar/showTabBar、showToastMessage、错误态 UI、toast、isFirstNodeInChapter(含 .sorted { $0.order < $1.order })、getChapterTitle。✅ + +### 4.2 应用时必须注意的合并范围(未应用,仅说明) + +当前 **VerticalScreenPlayerView.swift** 文件中除 `VerticalScreenPlayerView` 结构体外,还包含: + +- **HeaderConfig**、**DuolingoProgressBar**、**CourseProgressNavBar**(播放器依赖) +- **CompletionPlaceholderPage** 或 **LastPageSwipeModifier**(本方案中不再需要,可删除) +- **LessonPageView**、**LessonSkeletonView**、**LessonErrorView** 及后续所有类型与扩展 + +你提供的「最终修正版」只包含 **VerticalScreenPlayerView 结构体** 及 **PlayerItem** 枚举,未包含上述类型。因此: + +- **不能**用该片段整文件覆盖 **VerticalScreenPlayerView.swift**,否则会删掉 HeaderConfig、CourseProgressNavBar、LessonPageView 等,导致编译失败。 +- **正确做法**:在 **VerticalScreenPlayerView.swift** 内只做「局部替换」: + - 用本版的 **VerticalScreenPlayerView 结构体**(含 PlayerItem、body、loadMapData、handleBack、isFirstNodeInChapter 等)替换现有的 **VerticalScreenPlayerView** 结构体; + - 删除 **CompletionPlaceholderPage**(或占位页相关逻辑),不再添加 LastPageSwipeModifier; + - **保留** 文件内 **HeaderConfig**、**DuolingoProgressBar**、**CourseProgressNavBar**、**LessonPageView**、**LessonSkeletonView**、**LessonErrorView** 及之后所有内容不变。 + +### 4.3 realNodes 的 .sorted 与当前行为差异(可选核对) + +- **本版**:`let realNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }.sorted { $0.order < $1.order }` + 即对 **整课** 的节点列表按 `node.order` 做一次全局排序。 +- **当前实现**:`allCourseNodes = data.chapters.flatMap { $0.nodes }.filter { $0.status != .locked }` + 即 **不** 对 flatMap 后的列表排序,顺序为:先 chapter 顺序,再各 chapter 内 nodes 的数组顺序。 + +若后端 `node.order` 是 **按章节内** 的(例如每章都是 0,1,2,…),则对整课 flat 列表只按 `order` 排序会 **打乱章节顺序**(例如把各章 order=0 的节点排在一起)。若希望与当前行为完全一致,可考虑: + +- 要么 **去掉** realNodes 的 `.sorted { $0.order < $1.order }`,保持与当前一致的「章节顺序 + 章内数组顺序」; +- 要么在确认后端 `order` 为全局唯一或全局有序的前提下保留当前排序。 + +是否保留该 sort,可根据产品/后端约定决定;不影响三项修正与 6 条零影响条件。 + +--- + +## 五、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **三项修正** | 参数优先权、错误 Toast、isFirstNodeInChapter 排序均已落实。 | +| **6 条零影响** | 全部满足;对外接口与调用方无需改动。 | +| **CompletionView** | 可直接全量替换 `ios/WildGrowth/WildGrowth/Views/CompletionView.swift`。 | +| **VerticalScreenPlayerView** | 仅可替换 **结构体** 并删除占位页相关类型;必须保留同文件内 HeaderConfig、CourseProgressNavBar、LessonPageView 等所有其他类型,不能整文件覆盖。 | +| **realNodes 排序** | 与当前实现存在差异,是否保留 `.sorted { $0.order < $1.order }` 需结合后端 order 语义决定。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_粉紫掌机版_定稿审查报告.md b/ios/WildGrowth/COMPLETION_粉紫掌机版_定稿审查报告.md new file mode 100644 index 0000000..5cdb892 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_粉紫掌机版_定稿审查报告.md @@ -0,0 +1,78 @@ +# CompletionView 粉紫掌机版 — 定稿审查报告(不应用) + +**审查日期**:2025-01-29 +**范围**:Pink-Purple Gameboy Edition(赛博粉紫外壳 + 黑框大屏 + 蓝色胶囊按钮 + 数字滚动) +**结论**:仅审查、不修改仓库;逻辑继承正确,有一处待确认的 UI 细节。 + +--- + +## 1. 修改点落实情况 + +| 修改点 | 要求 | 定稿实现 | 结论 | +|--------|------|----------|------| +| **外壳颜色** | 赛博粉紫渐变 (Hot Pink → Deep Purple) | `shellGradient`:`Color(1, 0.35, 0.8)` → `Color(0.58, 0.2, 0.9)` | ✅ | +| **去除装饰** | 右下角勋章删掉,只留数据 | `Spacer()` 替代原勋章位,注释 "已删除勋章" | ✅ | +| **屏幕布局** | 严格复刻截图 | 上:TOTAL → 数字 → LESSONS;下:FOCUS TIME → 数字 min;无 ONLINE/电量 | ✅ | +| **按钮** | 蓝色胶囊,与粉紫撞色 | 亮蓝渐变 + 凹槽 + 高光,保留撞色效果 | ✅ | +| **动效** | 点击 → 屏幕闪白 → 数字匀速滚动 | `performActivation` 中 `isSystemOn = true` + `withAnimation(.linear(duration: 1.5))` | ✅ | + +--- + +## 2. 逻辑继承(FRC 要求) + +| 项目 | 要求 | 定稿实现 | 结论 | +|------|------|----------|------| +| **持久化 Key** | `has_revealed_course_\(courseId)` | 一致 | ✅ | +| **游客短路** | 不调网络,短延迟后直接激活 | `if userManager.isGuest { asyncAfter(0.5) { performActivation(0,0) }; return }` | ✅ | +| **游客延迟** | 极短假连接 | 0.5s | ✅ | +| **登录用户** | 拉取 fetchUserProfile 后激活 | `Task { sleep(0.8s); fetchUserProfile; performActivation(lessons, minutes) }` | ✅ | + +--- + +## 3. RollingNumberText 与 Animatable + +- `RollingNumberText` 正确实现 `Animatable`:`animatableData` 读写 `value`,`Double` 符合 `VectorArithmetic`,数字在 1.5s 内线性插值显示。 +- `displayLessons`、`displayMinutes` 初始为 0,激活时设为目标值,配合 `withAnimation(.linear(duration: 1.5))` 实现匀速滚动。 + +--- + +## 4. checkSystemStatus 与数据来源 + +- 已激活时:`displayLessons`、`displayMinutes` 从 `userManager.studyStats` 取,保证二次进入时显示正确。 +- 游客:`performActivation(0, 0)`,与未登录、无服务端统计一致。 +- 登录用户:`performActivation(lessons, minutes)` 使用 `fetchUserProfile` 后的 `studyStats`,正确。 + +--- + +## 5. 待确认 UI 细节 + +**按钮未按下时的图标**:当前在 `!isBooting && !isSystemOn` 时显示 `Image(systemName: "checkmark")`,与激活后相同。若希望更易理解「待操作」,可改为 `"arrow.down.circle.fill"` 或 `"checkmark.circle"` 等,属可选优化,非阻塞。 + +--- + +## 6. 可选小修正(按需) + +- **ForEach(0..<100)**:扫描线纹理的 `ForEach(0..<100)` 若在部分 Swift/SwiftUI 版本报错,可加上 `id: \.self`。 + +--- + +## 7. 接口与影响范围 + +| 项目 | 结论 | +|------|------| +| **初始化参数** | 三参数不变,调用方无需修改 | +| **替换方式** | 直接整文件替换 `CompletionView.swift` | +| **其他页面** | 无影响 | + +--- + +## 8. 总结 + +| 维度 | 结论 | +|------|------| +| **视觉修改** | ✅ 粉紫外壳、去勋章、复刻布局、蓝胶囊按钮、数字滚动 | +| **逻辑继承** | ✅ Key 回滚、游客短路、登录用户拉取逻辑一致 | +| **动效** | ✅ 屏幕闪白 + 1.5s 线性滚动 | +| **接口兼容** | ✅ 可直接替换 | + +**审查结论**:定稿代码满足设计要求,逻辑继承正确,可直接用于替换。**本次未对仓库做任何修改。** diff --git a/ios/WildGrowth/COMPLETION_给Gemini的反馈_不改逻辑层.md b/ios/WildGrowth/COMPLETION_给Gemini的反馈_不改逻辑层.md new file mode 100644 index 0000000..3999e44 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_给Gemini的反馈_不改逻辑层.md @@ -0,0 +1,42 @@ +# 给 Gemini 的反馈:完结页不要改逻辑层 + +## 核心要求 + +**坚持「点击按钮时拉后端」**,不要改逻辑层。 + +--- + +## 1. 数据流(必须保持) + +- **当前逻辑**:用户点击「标记完成」/「上传学习数据」等按钮 → **在 CompletionView 内部**调用后端拉取最新数据(如 `UserManager.shared.fetchUserProfile()`)→ 用拉取结果(如 `UserManager.shared.studyStats.lessons` / `.time`)展示。 +- **要求**:完结页的**数据来源与拉取时机**保持上述逻辑,即**点击按钮时在页面内拉后端**,不在父视图提前传「已完成小节数、专注时长」等业务数据。 + +--- + +## 2. 不要做的改动(逻辑层) + +- **不要**让 CompletionView 增加必选参数 `completedLessonCount`、`focusMinutes` 等由父视图传入的「业务数据」。 +- **不要**把「拉取最新统计」的责任从 CompletionView 挪到父视图(如 VerticalScreenPlayerView)或改成「进入页面时父视图传参」。 +- **不要**改变「点击按钮 → 调接口 → 用接口/本地统计结果展示」这一套流程;可以保留「上传/显影」的**交互与动效**,但**显影后展示的数据必须来自点击后拉取的结果**(例如继续用 UserManager.studyStats 或等价数据源)。 + +--- + +## 3. 可以做的(仅 UI / 视觉) + +- 可以改完结页的**视觉与动效**(例如拍立得、赛博海报、骨架、显影动画等)。 +- 可以改**按钮文案**(如「上传学习数据」「标记完成」)和**排版**(顶对齐、大数字等)。 +- 可以保留 **UserDefaults 显影状态**(已显影过则直接展示结果态,不重复播动画),只要结果态展示的数据仍来自**点击时拉取的后端/统计**(例如首次显影时点按钮拉取,之后用本地缓存或同一数据源)。 +- **回到我的内容**:必须调用 `navStore.switchToGrowthTab()`,回到技能 Tab。 + +--- + +## 4. 接口约定(建议) + +- CompletionView 的入参保持与现有一致或仅做**可选**扩展(如可选 `navigationPath`),**不要**新增必选的「业务数据」参数(如 `completedLessonCount: Int`、`focusMinutes: Int`)。 +- 小节数、专注时长等**在 CompletionView 内部**从现有数据源获取(如 UserManager.studyStats),并在**用户点击按钮后**通过现有或约定的接口拉取最新再展示。 + +--- + +## 5. 一句话总结 + +**只改完结页的 UI/动效/文案,不要改「点击按钮时拉后端、用拉取结果展示」的逻辑与数据层;不要通过父视图传入 completedLessonCount、focusMinutes 等业务数据。** diff --git a/ios/WildGrowth/COMPLETION_统一分页_零影响审查报告.md b/ios/WildGrowth/COMPLETION_统一分页_零影响审查报告.md new file mode 100644 index 0000000..12738cc --- /dev/null +++ b/ios/WildGrowth/COMPLETION_统一分页_零影响审查报告.md @@ -0,0 +1,122 @@ +# 完成页「统一分页架构」— 零影响审查报告(禁止应用) + +**审查目标**:若应用此方案,确保**其他页面、其他功能、其他逻辑一点也不受影响**。 +**结论**:仅审查,不修改仓库内任何文件。下文列出所有受影响点及达成「零影响」的**必要条件**。 + +--- + +## 一、方案会动到的文件与接口 + +| 文件 | 方案中的改动 | +|------|----------------| +| **CompletionView.swift** | 整文件替换:去掉 courseId;仅保留 courseTitle、completedLessonCount;纯 UI + switchToGrowthTab | +| **VerticalScreenPlayerView.swift** | 数据源改为 allItems (PlayerItem);去掉占位页与 onChange;init 去掉 navigationPath、isLastNode、courseTitle;handleBack 仅 dismiss();LessonPageView 传参可能变 | + +--- + +## 二、依赖关系与「零影响」条件 + +### 2.1 谁调用 VerticalScreenPlayerView? + +| 调用方 | 当前传参 | 方案中 init 若改为 3 参会怎样 | +|--------|----------|------------------------------| +| **GrowthView** (.player) | courseId, nodeId, initialScrollIndex: nil, **navigationPath: $growthPath**, **isLastNode**, **courseTitle** | ❌ 编译失败(多传 3 个参数) | +| **ProfileView** (.player) | 同上,navigationPath: $profilePath | ❌ 同上 | +| **DiscoveryView** (.player) | 同上,navigationPath: $homePath | ❌ 同上 | +| **NoteTreeView** (.player) | courseId, nodeId, initialScrollIndex(无 path / isLastNode / courseTitle) | ✅ 仍兼容 3 参 init | + +**零影响条件 1**: +**VerticalScreenPlayerView 的 init 必须保留现有 6 参签名**(courseId, nodeId, initialScrollIndex, navigationPath?, isLastNode?, courseTitle?),且默认值不变。内部可改用 allItems + PlayerItem,但对外接口不变,这样 GrowthView / ProfileView / DiscoveryView **无需改一行**。 + +--- + +### 2.2 谁使用 CourseNavigation 与 CompletionView? + +| 位置 | 当前行为 | 方案若不再 push .completion 会怎样 | +|------|----------|-------------------------------------| +| **GrowthView** | navigationDestination(.completion) → CompletionView(courseId, courseTitle, completedLessonCount) | 从「课程流」进完成页时不再走此分支(完成页在 TabView 内);若保留此分支,深链/通知若 push .completion 仍能展示 | +| **ProfileView** | 同上 | 同上 | +| **DiscoveryView** | 同上 | 同上 | +| **MapView** | 只 append .player,不直接 append .completion | ✅ 无影响 | +| **VerticalScreenPlayerView**(当前) | 滑到占位页时 append .completion | 方案中不再 append,完成页在 TabView 内 | + +**零影响条件 2**: +- **保留** `CourseNavigation.completion` 与三处 `.completion` 的 navigationDestination。 +- **CompletionView 仍保留三参**:courseId, courseTitle, completedLessonCount。这样: + - 从课程流进入时,完成页由播放器内 TabView 展示,不经过 navigationDestination; + - 若有深链/通知/其他入口直接 push .completion,三处 destination 仍能正确展示 CompletionView,且接口一致。 + +--- + +### 2.3 LessonPageView 的传参 + +| 当前传参 | 方案中若改为不传 navigationPath / 不传 courseTitle 会怎样 | +|----------|-----------------------------------------------------------| +| courseId, nodeId, currentGlobalNodeId, initialScrollIndex, headerConfig, **courseTitle**, **navigationPath** | LessonPageView 中两者均为可选;不传则 nil。若内部有逻辑依赖 path 或 courseTitle,可能受影响。 | + +**零影响条件 3**: +在「统一分页」的播放器内部,对 **LessonPageView** 的调用仍传入 **courseTitle**、**navigationPath**(用 VerticalScreenPlayerView 持有的 courseTitle、navigationPath),与当前一致。即:仅改数据源与最后一页渲染方式,不减少对 LessonPageView 的传参。 + +--- + +### 2.4 播放器内部逻辑(其他功能) + +| 当前能力 | 方案中若省略会怎样 | +|----------|--------------------| +| **handleBack** 用 navigationPath.removeLast() 或 dismiss() | 若改为仅 dismiss(),在 NavigationStack 内效果通常等价(pop 一层)。为保险起见,建议保留:有 path 且非空时 removeLast(),否则 dismiss()。 | +| **loadError / 错误态 UI** | 方案片段中未写,若删除则错误态消失。 | +| **showToast / toastMessage** | 同上,若删除则 toast 能力消失。 | +| **hideTabBar / showTabBar(onAppear / onDisappear)** | 若删除则播放器出现时 TabBar 可能仍显示。 | +| **currentPositionProgress**(不含占位页) | 方案中有「进度计算忽略完结页」,逻辑等价即可。 | +| **GeometryReader 等布局** | 若删除可能影响布局,建议保留与当前一致。 | + +**零影响条件 4**: +播放器内**保留**:loadError / 错误态 UI、showToast / toastMessage、hideTabBar / showTabBar、handleBack 的 path 优先逻辑(若保留 navigationPath 参数则一并保留)、以及现有布局与进度计算方式(仅把「占位页」换成虚拟 completion 页,进度仍只按真实小节算)。 + +--- + +### 2.5 笔记流(NoteTreeView / NoteListView) + +| 当前 | 说明 | +|------|------| +| 使用 NoteNavigationDestination.player,传 courseId, nodeId, initialScrollIndex | 不传 navigationPath、isLastNode、courseTitle;当前 init 中这些为可选且带默认值。 | + +**零影响条件 5**: +保持 VerticalScreenPlayerView 的 init 中 **navigationPath、isLastNode、courseTitle 为可选且默认 nil**。这样 NoteTreeView / NoteListView **无需任何修改**,行为不变(不传 path 则不会 push 完成页;统一分页下完成页在 TabView 内,笔记流仍不涉及 .completion 路由)。 + +--- + +### 2.6 其他引用 + +| 引用 | 影响 | +|------|------| +| **ContentBlockBuilder** 注释「与 VerticalScreenPlayerView 保持一致」 | 仅注释,不依赖接口或类型。 | +| **CourseNavigation** 枚举 | 保留 .map / .player / .completion 三 case,见零影响条件 2。 | +| **MapView** 中 append CourseNavigation.player(courseId, nodeId, isLastNode, courseTitle) | 不依赖播放器 init 是否接收这些参数,只要枚举不变即无影响。 | + +--- + +## 三、零影响检查清单(应用前必达) + +若要在「其他页面、其他功能、其他逻辑一点也不受影响」的前提下应用统一分页方案,需同时满足: + +| # | 条件 | 说明 | +|---|------|------| +| 1 | VerticalScreenPlayerView **init 保持 6 参**(courseId, nodeId, initialScrollIndex, navigationPath?, isLastNode?, courseTitle?) | GrowthView / ProfileView / DiscoveryView 不改动 | +| 2 | 保留 **CourseNavigation.completion** 及三处 **.completion** 的 navigationDestination | 深链/其他入口 push .completion 仍可用 | +| 3 | **CompletionView 保留三参**(courseId, courseTitle, completedLessonCount) | 与现有 destination 及枚举一致 | +| 4 | 播放器内对 **LessonPageView** 仍传 **courseTitle**、**navigationPath**(用播放器自身属性) | LessonPageView 行为不变 | +| 5 | 播放器内保留 **loadError、toast、hideTabBar/showTabBar、handleBack(path 优先)** 及现有布局/进度逻辑 | 错误、提示、TabBar、返回、进度均不受影响 | +| 6 | **NoteTreeView / NoteListView** 不修改 | 依赖 init 可选参数,已满足则零影响 | + +--- + +## 四、总结 + +- **按方案原文直接替换**(init 改为 3 参、CompletionView 去掉 courseId、播放器去掉 path/错误/toast/tabBar 等):**会**影响 GrowthView / ProfileView / DiscoveryView(编译或行为)、以及错误态/toast/TabBar/返回等逻辑。 +- **在满足上述 6 条零影响条件的前提下**,再引入 allItems + PlayerItem、TabView 最后一页渲染 CompletionView、不再 push .completion,则: + - 其他页面(含三 Tab、MapView、笔记流)**无需改**; + - 其他功能(错误、toast、TabBar、返回、进度、深链 .completion)**不受影响**; + - 其他逻辑(完课统计、LessonPageView、CourseNavigation)**保持一致**。 + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_统一分页_零影响版代码审查报告.md b/ios/WildGrowth/COMPLETION_统一分页_零影响版代码审查报告.md new file mode 100644 index 0000000..09b3084 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_统一分页_零影响版代码审查报告.md @@ -0,0 +1,97 @@ +# 完成页「统一分页 + 零影响」代码审查报告(禁止应用) + +**审查对象**:严格遵守 6 条零影响条件的 CompletionView + VerticalScreenPlayerView 完整代码(内部重构,外部兼容)。 +**结论**:仅审查,不修改仓库内任何文件。对照 6 条逐项核对,并列出需补全/修正的细节以达「绝对零影响」。 + +--- + +## 一、6 条零影响条件对照 + +| # | 条件 | 本版代码 | 结论 | +|---|------|----------|------| +| 1 | VerticalScreenPlayerView init 保持 6 参 | `init(courseId, nodeId, initialScrollIndex, navigationPath?, isLastNode?, courseTitle?)` 完整保留 | ✅ 满足 | +| 2 | 保留 CourseNavigation.completion 及三处 destination | 未改枚举与三 Tab;CompletionView 三参,destination 调用不变 | ✅ 满足 | +| 3 | CompletionView 保留三参 | `courseId`, `courseTitle`, `completedLessonCount` 均有 | ✅ 满足 | +| 4 | LessonPageView 仍传 courseTitle、navigationPath | 传 `courseTitle: mapData?.courseTitle`、`navigationPath: navigationPath` | ⚠️ 见下 4.1 | +| 5 | 保留 loadError、toast、hideTabBar/showTabBar、handleBack(path 优先) | 错误态、toast、tabBar、handleBack 均保留 | ⚠️ 见下 5.1、5.2 | +| 6 | NoteTreeView / NoteListView 不修改 | init 未改,笔记流仍只传 3 参 | ✅ 满足 | + +--- + +## 二、CompletionView 审查 + +- **接口**:courseId / courseTitle / completedLessonCount 全保留,与 CourseNavigation.completion 及三处 destination 一致。✅ +- **内部**:纯 UI(粉紫勋章)+ navStore.switchToGrowthTab(),无 navigationPath、无侧滑 Hack。✅ +- **视觉**:顶部「已完成」、底部「回到我的内容」淡蓝、无返回按钮。与需求一致。✅ +- **无遗漏**:无依赖 dismiss、无依赖 path,作为 TabView 一页或作为 push 目标均可。✅ + +**结论**:CompletionView 满足零影响条件,无需改动。 + +--- + +## 三、VerticalScreenPlayerView 审查 + +### 3.1 已满足项 + +- **Init**:6 参完整,与现有 GrowthView / ProfileView / DiscoveryView / NoteTreeView / NoteListView 调用兼容。✅ +- **PlayerItem**:lesson(MapNode) + completion,id 分别为 node.id 与 `"COMPLETION_PAGE"`。✅ +- **loadMapData**:realNodes + append(.completion),allItems 构造正确;loadError = nil 与 catch 内 set loadError 均有。✅ +- **currentPositionProgress**:仅用 lesson 项计算,完结页时返回 1.0,逻辑正确。✅ +- **handleBack**:path 非空则 removeLast(),否则 dismiss()。✅ +- **hideTabBar / showTabBar / toast**:保留。✅ +- **LessonPageView**:传 courseId, nodeId, currentGlobalNodeId, initialScrollIndex, headerConfig, courseTitle, navigationPath。✅ +- **CompletionView(内嵌)**:传 courseId, courseTitle, completedLessonCount。✅ + +### 3.2 需补全或修正的细节(达「绝对零影响」) + +#### 4.1 LessonPageView 的 courseTitle 传参(对应条件 4) + +- **本版**:`courseTitle: mapData?.courseTitle`(仅用加载后的 mapData)。 +- **当前实现**:`courseTitle: courseTitle`(用调用方传入的 courseTitle,如 MapView 的 data.courseTitle)。 +- **差异**:加载完成前 mapData 为 nil,本版会传 nil;当前实现一进入就有值。若希望与现有行为完全一致,建议传 **`courseTitle ?? mapData?.courseTitle`**,优先用传入值,再回退到加载结果。 + +#### 5.1 loadMapData 失败时的 Toast(对应条件 5) + +- **当前实现**:catch 中除 set loadError 外,还调用 `showToastMessage("加载失败")`。 +- **本版**:catch 中只 set loadError,未调用 showToastMessage。 +- **建议**:在 catch 的 MainActor.run 内补上 **`showToastMessage("加载失败")`**,与现有体验一致。 + +#### 5.2 isFirstNodeInChapter 实现(对应条件 5:逻辑不变) + +- **当前实现**:在 chapter 内取 `validNodes = chapter.nodes.filter(locked).sorted { $0.order < $1.order }`,再 `validNodes.first?.id == nodeId`(即按 **order** 排序后的「第一章第一节」)。 +- **本版**:`chapter.nodes.filter(locked).first`,未按 order 排序,相当于用数组顺序的「第一个」。 +- **风险**:若后端/本地 chapter.nodes 顺序与 order 不一致,本版可能与当前表现不同。 +- **建议**:与当前保持一致,使用 **`validNodes = chapter.nodes.filter { $0.status != .locked }.sorted { $0.order < $1.order }`,再 `validNodes.first?.id == nodeId`**。 + +#### 5.3 其他可选一致性(非必须) + +- **空状态文案**:当前为「暂无内容」+「该课程还没有可用的学习内容」;本版为「暂无内容」+ 单行。若需完全一致可补副标题,否则可保留本版简化。 +- **GeometryReader**:当前 body 最外层包了一层 GeometryReader;本版未包。若当前无依赖 geo 的布局,可不再加;若有,需保留或等价处理。 +- **Import**:hideTabBar/showTabBar 使用 UIApplication,需 **import UIKit**。若文件当前已含 UIKit 则无需改;否则需补。 + +--- + +## 四、对外暴露与调用方 + +| 调用方 / 入口 | 是否需改 | 说明 | +|---------------|----------|------| +| GrowthView / ProfileView / DiscoveryView(.player / .completion) | 否 | init 与 CompletionView 三参未变 | +| MapView(append .player) | 否 | 枚举与参数不变 | +| NoteTreeView / NoteListView(.player) | 否 | 仍只传 3 参,可选参默认 nil | +| CourseNavigation 枚举 | 否 | 未改 | +| navigationDestination(.completion) | 否 | 仍用 CompletionView(courseId, courseTitle, completedLessonCount) | + +**结论**:在补全 4.1、5.1、5.2 后,对外接口与所有调用方均可保持零改动、零行为差异。 + +--- + +## 五、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **6 条零影响** | 条件 1、2、3、6 已满足;条件 4、5 在按 3.2 补全后可达「绝对零影响」。 | +| **CompletionView** | 可直接采用,无需改。 | +| **VerticalScreenPlayerView** | 建议补全:① LessonPageView 传 `courseTitle ?? mapData?.courseTitle`;② loadMapData 失败时 `showToastMessage("加载失败")`;③ isFirstNodeInChapter 按 order 排序取 first。 | +| **其他页面 / 功能 / 逻辑** | 在以上补全前提下,其他页面、其他功能、其他逻辑均不受影响。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_统一分页方案_审查报告.md b/ios/WildGrowth/COMPLETION_统一分页方案_审查报告.md new file mode 100644 index 0000000..c894d09 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_统一分页方案_审查报告.md @@ -0,0 +1,64 @@ +# 完成页「统一分页架构」— 审查报告(禁止应用) + +**审查对象**:虚拟节点 (PlayerItem.completion) 作为 TabView 最后一页,CompletionView 内嵌于播放器;是否导致「完成小节统计虚高」。 +**结论**:仅审查,不修改仓库内任何文件。 + +--- + +## 一、核心问题:会不会导致完成小节统计虚高? + +**结论:不会。** + +### 1.1 数据流梳理 + +| 环节 | 来源 / 行为 | 是否计入「完成小节」 | +|------|-------------|----------------------| +| **展示数字** | CompletionView 显示的 `completedLessonCount` 来自 `UserManager.shared.studyStats.lessons` | 仅展示,不写入 | +| **统计来源** | `studyStats.lessons` 由后端/用户接口(如 `completed_lessons`)拉取并写入 UserManager | 后端口径为真实完课数 | +| **完课上报** | 仅在 **LessonPageView** 内,当该节进度 ≥ 1.0 时调用 `LearningService.shared.completeLesson(nodeId: ...)` | 只对**真实课程节点**上报 | +| **虚拟节点** | `PlayerItem.completion` 仅存在于前端的 `allItems`,id 为 `"COMPLETION_PAGE"` | 不参与任何完课 API,无 nodeId | + +### 1.2 为何不会虚高 + +- **总结页不是一节课**:虚拟节点只用于 TabView 的「最后一页」展示,没有对应的 `MapNode`,也不会调用 `completeLesson(nodeId:)`。 +- **完课只发生在 LessonPageView**:只有渲染 `PlayerItem.lesson(node)` 时才会加载该节的进度、在达到条件时上报完课;渲染 `PlayerItem.completion` 时只渲染 CompletionView,没有任何完课或统计写入逻辑。 +- **展示用既有统计**:CompletionView 只是读取并展示 `UserManager.shared.studyStats.lessons`,不修改该值;该值的增加只来自真实小节在 LessonPageView 中的完课上报与后端同步。 + +因此:**把总结页当作 TabView 最后一页、用虚拟节点渲染 CompletionView,不会把总结页算成一节,也不会导致完成小节统计虚高。** + +--- + +## 二、方案本身审查(与当前实现对比) + +### 2.1 优点 + +- **交互统一**:左滑/右滑完全由 TabView(UIScrollView)负责,无需自定义 DragGesture、无需 SwipeBackEnabler。 +- **CompletionView 职责单一**:只做 UI 与「回到技能页根」按钮,不碰 navigationPath、不碰侧滑 Hack。 +- **状态集中**:完结页是 `PlayerItem` 枚举的一支,与课程节点同属一个数据源,逻辑清晰。 + +### 2.2 需注意的改动(若将来应用) + +1. **VerticalScreenPlayerView 的 init** + 方案中去掉了 `navigationPath`、`isLastNode`、`courseTitle`。当前 **GrowthView / ProfileView / DiscoveryView** 的 `.player` 分支会传这三个参数;若直接按方案改 init,调用方会报错。需要二选一: + - 保留这三个参数(兼容现有调用),或 + - 同时改三处调用方,不再传这些参数。 + +2. **CompletionView 的接口** + 方案中 CompletionView 只有 `courseTitle` 和 `completedLessonCount`(无 `courseId`)。若项目中别处仍通过 `CourseNavigation.completion(courseId: ...)` 等 push 进 CompletionView,则需保留 `courseId` 或同步改那些入口。 + +3. **进度条与 TabBar** + 当前实现有 `currentPositionProgress`(不含占位页)、hideTabBar/showTabBar、loadError、toast 等。方案里进度条在 `currentNodeId == "COMPLETION_PAGE"` 时隐藏,其余若省略需确认是否要保留(如错误态、toast、tabBar 隐藏)。 + +4. **笔记流** + NoteTreeView / NoteListView 里用的 VerticalScreenPlayerView 目前传的是 `NoteNavigationDestination.player`,不传 navigationPath。若播放器 init 去掉 navigationPath,笔记流不受影响;若保留可选 navigationPath 以兼容,也需在方案里写明。 + +--- + +## 三、审查结论汇总 + +| 问题 | 结论 | +|------|------| +| **会不会导致完成小节统计虚高?** | **不会**。总结页只是虚拟的一页,用于展示;完课统计只由 LessonPageView 对真实节点调用 completeLesson 产生,CompletionView 仅读取 studyStats.lessons 做展示。 | +| 统一分页架构本身 | 交互简单、无需手势与侧滑 Hack,CompletionView 可做纯 UI;若应用需处理 init 与调用方兼容、以及错误/toast/tabBar 等现有能力是否保留。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_赛博拍立得Final_审查报告.md b/ios/WildGrowth/COMPLETION_赛博拍立得Final_审查报告.md new file mode 100644 index 0000000..07c134a --- /dev/null +++ b/ios/WildGrowth/COMPLETION_赛博拍立得Final_审查报告.md @@ -0,0 +1,55 @@ +# 「赛博拍立得 Final」版 CompletionView 审查报告(禁止应用) + +**审查对象**:Gemini 提供的 Cyber Polaroid Final 版 CompletionView(拍立得隐喻、UserDefaults 显影状态、navStore.switchToGrowthTab()、真实数据由参数传入)。 +**结论**:仅审查,**不应用、不修改**仓库内任何文件。 + +--- + +## 一、需求符合性 + +| 项目 | 需求/说明 | 本版实现 | 结论 | +|------|-----------|----------|------| +| **回到我的内容** | 必须调用 `navStore.switchToGrowthTab()`,回到技能 Tab | `handleBackToContent()` 内先 `navStore.switchToGrowthTab()`,再清 path 或 dismiss | ✅ 符合 | +| **真实数据** | 小节数、专注时长由父视图传入,不写死 | `completedLessonCount`、`focusMinutes` 均为 `let`,由调用方传入 | ✅ 符合 | +| **状态持久化** | 用 UserDefaults 记录「该课程已显影」 | `storageKey = "has_revealed_course_\(courseId)"`,显影后 `set(true)`,onAppear 时 `checkDevelopmentStatus()` | ✅ 符合 | +| **视觉与交互** | 未显影 → 上传/显影 → 赛博海报;顶对齐、大数字 | UndevelopedFilm + DevelopedPoster,排版与说明一致 | ✅ 符合 | + +--- + +## 二、接口变更与对「其他页面」的影响 + +| 项目 | 说明 | +|------|------| +| **CompletionView 入参** | 本版为 **必选**:`courseId`, `courseTitle`, `completedLessonCount`, `focusMinutes`;**可选**:`navigationPath`。 | +| **当前调用处** | 仓库内仅 **VerticalScreenPlayerView** 一处构造 CompletionView,当前传参为:`courseId`, `courseTitle`, `navigationPath`(**未传** `completedLessonCount`、`focusMinutes`)。 | +| **影响** | 若只替换 CompletionView.swift 而**不修改调用方**,会因缺少 `completedLessonCount`、`focusMinutes` 两个必选参数而**编译失败**。 | + +因此:**必须同时修改 VerticalScreenPlayerView** 中构造 CompletionView 的那一行,补上: + +- `completedLessonCount: UserManager.shared.studyStats.lessons`(或你项目里等价的数据源) +- `focusMinutes: UserManager.shared.studyStats.time`(或 0,若暂无专注时长) + +**其他页面**:当前无其它地方使用 CompletionView,CourseNavigation 也无 `.completion` case,故除 VerticalScreenPlayerView 这一处调用外,**无需改其它页面**;逻辑与展示也不受影响。 + +--- + +## 三、小结:是否「只动完结页」 + +| 维度 | 结论 | +|------|------| +| **仅替换 CompletionView.swift** | ❌ 不够。本版多了两个必选参数,**必须**在 VerticalScreenPlayerView 中补传 `completedLessonCount` 与 `focusMinutes`,否则无法编译。 | +| **替换 CompletionView + 修改 VerticalScreenPlayerView 内一行调用** | ✅ 可做到。其它页面(GrowthView / ProfileView / MapView / CourseNavigation 等)逻辑与展示均不受影响。 | +| **若希望零改动调用方** | 可将 `focusMinutes` 改为带默认值,例如 `focusMinutes: Int = 0`;`completedLessonCount` 若希望与现有「由父视图传入」一致,建议保留必选并由 VerticalScreenPlayerView 传入。 | + +--- + +## 四、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **需求** | 回到技能 Tab、真实数据由参数传入、UserDefaults 持久化、拍立得交互与排版均符合说明。 | +| **接口** | 新增必选参数 `completedLessonCount`、`focusMinutes`,**会**影响当前唯一调用方 VerticalScreenPlayerView,需补参。 | +| **其他页面** | 仅 VerticalScreenPlayerView 需改一行调用;其余页面与逻辑、展示均不受影响。 | +| **建议** | 不应用本报告所述代码;若采用本版,需同步在 VerticalScreenPlayerView 中为 CompletionView 传入 `completedLessonCount` 与 `focusMinutes`。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_赛博拍立得版_审查报告.md b/ios/WildGrowth/COMPLETION_赛博拍立得版_审查报告.md new file mode 100644 index 0000000..27ae8cd --- /dev/null +++ b/ios/WildGrowth/COMPLETION_赛博拍立得版_审查报告.md @@ -0,0 +1,51 @@ +# 「赛博拍立得」版 CompletionView 审查报告(禁止应用) + +**审查对象**:Gemini 提供的 Cyber Polaroid 版 CompletionView(拍立得隐喻、上传学习数据、hasUploadedData 状态持久化)。 +**结论**:仅审查,**不应用、不修改**仓库内任何文件。 + +--- + +## 一、与当前需求的偏差 + +| 项目 | 当前需求 / 现有约定 | Gemini 本版实现 | 结论 | +|------|---------------------|-----------------|------| +| **底部按钮语义** | 点击「回到我的内容」应**回到技能 Tab(我的课程列表)**,即 `navStore.switchToGrowthTab()` | `handlePopToRoot()` 仅做 `path.wrappedValue = NavigationPath()` 或 `dismiss()`,**未切到技能 Tab** | ❌ 行为不符:从发现/个人 Tab 进入完结页时,点按钮只会清栈或 dismiss,仍停留在当前 Tab | +| **数据来源** | 共完成小节数来自接口/本地统计(如 `UserManager.shared.studyStats.lessons`),仅展示 | 新增 `focusMinutes: Int = 45` 写死;显影态展示「专注时长 (MIN)」,数据非来自现有统计接口 | ⚠️ 若产品无「专注时长」需求,属多余展示;若有,需接真实数据源 | +| **完结页入口** | 作为 TabView 最后一页内嵌,无「上传」步骤,仅展示 + 回到技能 Tab | 未显影 → 点击「上传学习数据」→ 模拟 1.5s 显影 → 显影态;依赖 `hasUploadedData` 状态 | ❌ 交互模型与当前「统一分页 + 纯展示」不一致,易与真实学习数据上报逻辑混淆 | + +--- + +## 二、接口与调用方影响 + +| 项目 | 说明 | +|------|------| +| **CompletionView 入参** | Gemini 版为 `courseId, courseTitle, completedLessonCount, focusMinutes=45, navigationPath?`。当前工程若为 `courseId, courseTitle, navigationPath` 三参,需在调用处补传 `completedLessonCount`(如 `UserManager.shared.studyStats.lessons`)。 | +| **VerticalScreenPlayerView** | 若已按此前约定传 `CompletionView(courseId:courseTitle:completedLessonCount:navigationPath:)`,则参数兼容;**无需改其他页面文件**。 | +| **GrowthView / ProfileView / DiscoveryView** | 其 `navigationDestination(for: .completion)` 仍为 `CompletionView(courseId:courseTitle:completedLessonCount:)`;若 Gemini 版增加可选 `navigationPath`,调用处可不传或传入对应 path,**其他页面逻辑与展示不受影响**。 | + +结论:**仅替换 CompletionView 时,其他页面无需改代码即可编译**;但完结页**自身行为**会变(见上表)。 + +--- + +## 三、是否影响其他页面的逻辑与展示 + +| 维度 | 结论 | +|------|------| +| **其他页面逻辑** | 不受影响。仅 CompletionView 内部实现与状态变化。 | +| **其他页面展示** | 不受影响。无改动其他 View 或导航结构。 | +| **完结页自身逻辑与展示** | **会变**:从「勋章 + 点击点亮 + 回到我的内容 → 技能 Tab」变为「拍立得未显影 → 上传数据 → 显影 + 专注时长 + 回到我的内容 → 清栈/dismiss」,且**不切技能 Tab**。 | + +因此:**其他页面逻辑和展示不受影响**;**完结页的交互与目标(回到技能 Tab)会受影响**,需按需求修正。 + +--- + +## 四、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **其他页面** | 逻辑与展示均不受影响;仅替换 CompletionView 时调用方可不改或仅补参。 | +| **底部按钮** | 未调用 `navStore.switchToGrowthTab()`,不符合「回到技能 Tab-我的课程列表」需求,需补回。 | +| **数据与交互** | 写死 focusMinutes、上传模拟、hasUploadedData 与当前「仅展示接口/本地统计 + 统一分页」不一致;若采用本版,需与产品/接口对齐并接入真实数据。 | +| **建议** | 不应用本版;若保留「赛博拍立得」视觉,需在不动其他页面的前提下:① 底部按钮改为 `navStore.switchToGrowthTab()`;② 移除或对接「上传学习数据」与「专注时长」逻辑,与现有统计与导航一致。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/COMPLETION_迭代_完整代码.md b/ios/WildGrowth/COMPLETION_迭代_完整代码.md new file mode 100644 index 0000000..99fc8f3 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_迭代_完整代码.md @@ -0,0 +1,288 @@ +# 完成页迭代 — 完整代码(仅交付,禁止自动应用) + +以下为审查修正后的完整代码。**关键修正**:底部「回到我的内容」改为 `navStore.switchToGrowthTab()`,不再使用 `navigationPath = NavigationPath()`,以保证从任意 Tab 进入都能回到技能页 Tab。 + +--- + +## 1. CompletionView.swift(整文件替换) + +```swift +import SwiftUI + +// MARK: - 🏆 课程完结页 (Gesture Flow Edition) +// 交互:左滑进入(上级控制),右滑返回(系统原生),底部点击回技能页 Tab +struct CompletionView: View { + let courseId: String + let courseTitle: String? + let completedLessonCount: Int + + @EnvironmentObject private var navStore: NavigationStore + @Environment(\.dismiss) private var dismiss + + @State private var isSealed = false + @State private var breathingOpacity: Double = 0.3 + @State private var contentOpacity: Double = 0 + + var body: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 0) { + Spacer().frame(height: 60) + + Spacer() + + ZStack { + Circle() + .strokeBorder( + isSealed ? + AnyShapeStyle( + LinearGradient( + colors: [Color.brandVital, Color.cyberIris], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) : + AnyShapeStyle(Color.inkSecondary.opacity(0.3)), + style: StrokeStyle(lineWidth: isSealed ? 4 : 1, dash: isSealed ? [] : [5, 5]) + ) + .frame(width: 220, height: 220) + .shadow( + color: isSealed ? Color.brandVital.opacity(0.4) : .clear, + radius: 20, x: 0, y: 0 + ) + .scaleEffect(isSealed ? 1.0 : 0.95) + .opacity(isSealed ? 1.0 : breathingOpacity) + .animation(.easeInOut(duration: 0.5), value: isSealed) + + if isSealed { + VStack(spacing: 8) { + Text("已完成的") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.inkSecondary) + + Text("第 \(completedLessonCount) 节") + .font(.system(size: 36, weight: .bold, design: .serif)) + .foregroundStyle( + LinearGradient( + colors: [Color.brandVital, Color.cyberIris], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } else { + Text("完") + .font(.system(size: 80, weight: .light, design: .serif)) + .foregroundColor(.inkPrimary.opacity(0.2)) + .opacity(breathingOpacity) + } + } + .contentShape(Circle()) + .onTapGesture { + triggerInteraction() + } + + Spacer() + + Button { + handleReturnToRoot() + } label: { + HStack(spacing: 4) { + Text("回到我的内容") + .font(.system(size: 15, weight: .medium)) + Image(systemName: "arrow.right") + .font(.system(size: 12)) + } + .foregroundColor(.brandVital) + .padding(.vertical, 12) + .padding(.horizontal, 32) + .background(Capsule().fill(Color.brandVital.opacity(0.05))) + } + .opacity(contentOpacity) + .padding(.bottom, 80) + } + } + .toolbar(.hidden, for: .navigationBar) + .modifier(SwipeBackEnablerModifier()) + .onAppear { + startBreathing() + } + } + + private func startBreathing() { + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + breathingOpacity = 0.8 + } + } + + private func triggerInteraction() { + guard !isSealed else { return } + let generator = UIImpactFeedbackGenerator(style: .heavy) + generator.impactOccurred() + withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { + isSealed = true + } + withAnimation(.easeOut.delay(0.5)) { + contentOpacity = 1.0 + } + } + + /// 回到技能页 Tab 根目录(与当前「继续学习」一致) + private func handleReturnToRoot() { + navStore.switchToGrowthTab() + } +} + +// MARK: - 侧滑返回:隐藏导航栏时仍可右滑 pop +struct SwipeBackEnablerModifier: ViewModifier { + func body(content: Content) -> some View { + content.background(SwipeBackEnabler()) + } +} + +private struct SwipeBackEnabler: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { UIViewController() } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + DispatchQueue.main.async { + if let nc = uiViewController.navigationController { + nc.interactivePopGestureRecognizer?.delegate = nil + nc.interactivePopGestureRecognizer?.isEnabled = true + } + } + } +} +``` + +--- + +## 2. VerticalScreenPlayerView.swift(仅改动部分) + +### 2.1 保持现有 init 与属性 + +- `navigationPath: Binding?` 保持可选,NoteTreeView / NoteListView 不传时仍不 push 完成页。 +- `isLastNode`, `courseTitle` 等保持不变。 + +### 2.2 替换「加载成功」分支:去掉占位页,仅用左滑手势 push + +**原逻辑**(约 146–186 行): +`TabView` 内 `ForEach(allCourseNodes)` + `CompletionPlaceholderPage().tag("wg://completion")`,外加 `.onChange(of: currentNodeId)` 在 `newId == "wg://completion"` 时 append completion。 + +**新逻辑**: +- `TabView` 内**仅** `ForEach(allCourseNodes)`,每页在最后一节上挂 `LastPageSwipeModifier`,左滑时调用 `triggerCompletionNavigation`。 +- **删除** `CompletionPlaceholderPage` 及其 tag、**删除** `.onChange(of: currentNodeId)` 中与 `"wg://completion"` 相关的逻辑。 +- 新增 `@State private var isNavigatingToCompletion = false`(防抖),以及 `triggerCompletionNavigation()`、`LastPageSwipeModifier`。 + +**替换后的内容区代码**(直接替换原 `else if !allCourseNodes.isEmpty { ... }` 整块): + +```swift +} else if !allCourseNodes.isEmpty { + TabView(selection: $currentNodeId) { + ForEach(allCourseNodes, id: \.id) { node in + let isFirst = isFirstNodeInChapter(nodeId: node.id) + let chapterTitle = getChapterTitle(for: node.id) + + LessonPageView( + courseId: courseId, + nodeId: node.id, + currentGlobalNodeId: $currentNodeId, + initialScrollIndex: node.id == initialNodeId ? initialScrollIndex : nil, + headerConfig: HeaderConfig( + showChapterTitle: isFirst, + chapterTitle: chapterTitle + ), + courseTitle: courseTitle, + navigationPath: navigationPath + ) + .tag(node.id) + .modifier(LastPageSwipeModifier( + isLastPage: node.id == allCourseNodes.last?.id, + onSwipeLeft: triggerCompletionNavigation + )) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea(.container, edges: .bottom) +} +``` + +### 2.3 新增状态与函数(放在 Logic Helpers 区域) + +```swift +@State private var isNavigatingToCompletion = false + +private func triggerCompletionNavigation() { + guard !isNavigatingToCompletion, let path = navigationPath else { return } + isNavigatingToCompletion = true + + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + + let count = UserManager.shared.studyStats.lessons + path.wrappedValue.append(CourseNavigation.completion( + courseId: courseId, + courseTitle: courseTitle, + completedLessonCount: count + )) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + isNavigatingToCompletion = false + } +} +``` + +### 2.4 删除 onAppear 中「从完成页返回切回最后一节」逻辑 + +**删除**: + +```swift +if currentNodeId == "wg://completion", let lastId = allCourseNodes.last?.id { + currentNodeId = lastId +} +``` + +(去掉占位页后不再存在 `"wg://completion"` 选中的情况。) + +### 2.5 删除 CompletionPlaceholderPage,新增 LastPageSwipeModifier + +**删除** `CompletionPlaceholderPage` 结构体。 + +**在** `// MARK: - 📄 单页课程视图` **之前** 新增: + +```swift +// MARK: - 最后一页左滑:仅在最后一节挂载,左滑 push 完成页 +private struct LastPageSwipeModifier: ViewModifier { + let isLastPage: Bool + let onSwipeLeft: () -> Void + + func body(content: Content) -> some View { + content + .simultaneousGesture( + DragGesture(minimumDistance: 20, coordinateSpace: .local) + .onEnded { value in + guard isLastPage else { return } + if value.translation.width < -60, + abs(value.translation.width) > abs(value.translation.height) { + onSwipeLeft() + } + } + ) + } +} +``` + +--- + +## 3. 调用方(GrowthView / ProfileView / DiscoveryView) + +**无需改动**。CompletionView 仍为三参:`courseId`, `courseTitle`, `completedLessonCount`,依赖 `@EnvironmentObject navStore`,底部用 `switchToGrowthTab()` 回到技能页 Tab。 + +--- + +## 4. 小结 + +- **CompletionView**:无顶部返回、无打字机;赛博印章交互;右滑依赖 `SwipeBackEnablerModifier`;底部「回到我的内容」= `navStore.switchToGrowthTab()`。 +- **VerticalScreenPlayerView**:去掉占位页与 `onChange` 完成页逻辑;最后一节左滑用 `LastPageSwipeModifier` + `triggerCompletionNavigation` push 完成页;保留可选 `navigationPath`,笔记流不变。 +- **不修改** GrowthView / ProfileView / DiscoveryView 的 `CompletionView(...)` 调用。 diff --git a/ios/WildGrowth/COMPLETION_迭代_审查报告.md b/ios/WildGrowth/COMPLETION_迭代_审查报告.md new file mode 100644 index 0000000..411559e --- /dev/null +++ b/ios/WildGrowth/COMPLETION_迭代_审查报告.md @@ -0,0 +1,84 @@ +# 完成页迭代方案 — 审查报告(禁止应用) + +**审查对象**:左滑进完成页、右滑回最后一节;无顶部返回;无打字机金句;底部「回到我的内容」回到技能页 Tab。 +**结论**:只做审查与完整代码输出,不修改仓库文件。 + +--- + +## 一、需求与方案对照 + +| 需求 | 方案 | 审查结论 | +|------|------|----------| +| 不想要点开页面导航栏的返回按钮 | 移除顶部导航栏和返回按钮,仅顶部留白 | ✅ 一致 | +| 右滑回到最后一个小节 | `.enableSwipeBack()` + `SwipeBackEnabler` 强制开启侧滑返回 | ⚠️ 见下文「侧滑返回」 | +| 最后小节左滑进完成页,右滑回最后小节 | 播放器最后一节挂 `LastPageSwipeModifier` 左滑 push;完成页右滑 = 系统 pop | ✅ 一致 | +| 不要打字机金句 | 移除打字机区域 | ✅ 一致 | +| 底部「回到我的内容」回到技能页 Tab | 方案写的是 `navigationPath = NavigationPath()` | ❌ **逻辑错误**,见下 | + +--- + +## 二、关键问题:底部按钮语义 + +**需求**:底部「回到我的内容」要回到**技能页 Tab**(即技能 Tab 根:课程列表)。 + +**方案中的实现**:`handleReturnToRoot()` 里写的是 `navigationPath = NavigationPath()`,即清空**当前传入的 path**。 + +- 若从**技能 Tab**进入:传的是 `growthPath`,清空后 = 技能 Tab 根 ✅ +- 若从**发现 Tab**进入:传的是 `homePath`,清空后 = **发现 Tab 根**,不会切到技能 Tab ❌ +- 若从**我的 Tab**进入:传的是 `profilePath`,清空后 = **我的 Tab 根**,不会切到技能 Tab ❌ + +因此:**仅清空当前 path 无法满足「无论从哪个 Tab 进,都回到技能页 Tab」**。 + +**正确做法**:底部按钮应调用 **`navStore.switchToGrowthTab()`**(切到技能 Tab + 清空 `growthPath`),与当前线上 CompletionView 的「继续学习」一致。 +- CompletionView 需保留 **`@EnvironmentObject var navStore`** +- 不需要为「回到我的内容」传入 **`@Binding var navigationPath`**(调用方无需改传参) + +**完整代码中已按此修正**:底部按钮调用 `navStore.switchToGrowthTab()`,不传 `navigationPath`。 + +--- + +## 三、侧滑返回(SwipeBackEnabler) + +- 方案用 **`SwipeBackEnabler`**(`UIViewControllerRepresentable`)在 `.background` 里查找 `navigationController` 并打开 `interactivePopGestureRecognizer`,以在隐藏导航栏时恢复右滑返回。 +- **风险**:`makeUIViewController` 返回的是一颗裸 `UIViewController()`,其 `navigationController` 在部分时机可能仍为 nil(例如尚未挂到 NavigationStack 上),`DispatchQueue.main.async` 能缓解但无法完全保证。若遇真机偶发无效,可考虑: + - 在 `updateUIViewController` 中轮询/延迟再取一次 `navigationController`,或 + - 使用 `UINavigationController` 子类 / 注入方式保证拿到的必为当前栈。 +- **结论**:实现可保留,建议在真机多场景(从三个 Tab 进入完成页后右滑)验证;若失效再加强时机或注入方式。 + +--- + +## 四、VerticalScreenPlayerView 与占位页 + +- 方案采用「**不使用占位页**,仅最后一节左滑手势 push 完成页」:TabView 只渲染 `ForEach(allCourseNodes)`,不再多一页 `CompletionPlaceholderPage`。 +- **影响**: + - 进入完成页**仅剩**「在最后一节左滑」这一种方式;「滑到下一空白页再进完成页」的路径被移除。 + - 从完成页右滑或 dismiss 后,播放器不再存在「当前是占位页、需在 onAppear 里切回最后一节」的状态,可删除 `onAppear` 里对 `currentNodeId == "wg://completion"` 的处理。 +- **与现有调用方**:NoteTreeView / NoteListView 不传 `navigationPath`,`triggerCompletionNavigation` 里需 `guard let path = navigationPath else { return }`,从笔记进的播放器仍不会 push 完成页,行为不变。 + +--- + +## 五、CompletionView 视觉与接口 + +- **视觉**:从当前 3D 翻转卡片 + 打字机,改为「赛博印章」:圆环 + 未盖章「完」/ 盖章后「已完成的第 N 节」+ 底部「回到我的内容」。 +- **接口**: + - 保留:`courseId`, `courseTitle`, `completedLessonCount`。 + - 不增加 `@Binding var navigationPath`(底部用 `navStore.switchToGrowthTab()`)。 + - 保留 `@EnvironmentObject var navStore`。 +- **调用方**:GrowthView / ProfileView / DiscoveryView 的 `.completion` 分支**无需**新增参数,仍为三参构造。 + +--- + +## 六、审查结论汇总 + +| 项 | 结论 | +|----|------| +| 顶部去掉返回按钮 | ✅ 方案正确 | +| 右滑回最后一节 | ✅ 思路正确;SwipeBackEnabler 需真机验证,偶发需加强时机 | +| 最后小节左滑进完成页 | ✅ 保留 LastPageSwipeModifier 即可 | +| 去掉打字机金句 | ✅ 方案正确 | +| 底部回到技能页 Tab | ❌ 方案用「清空当前 path」会错;应用 `navStore.switchToGrowthTab()`,完整代码已改 | +| 调用方改动 | ✅ CompletionView 不需新参数;VerticalScreenPlayerView 保持可选 `navigationPath`,笔记流不受影响 | + +--- + +**完整代码见下(仅作交付,不写入仓库)。** diff --git a/ios/WildGrowth/COMPLETION_重复修改审查.md b/ios/WildGrowth/COMPLETION_重复修改审查.md new file mode 100644 index 0000000..d705b1e --- /dev/null +++ b/ios/WildGrowth/COMPLETION_重复修改审查.md @@ -0,0 +1,43 @@ +# 主题色 & 思考流 — 重复修改审查 + +## 1. 主题色 (theme_color) + +| 位置 | 作用 | 是否重复 | +|------|------|----------| +| **backend/src/controllers/courseController.ts** | 管理后台创建课程后,`create` 不含 themeColor,随后 `update` 写入 `generateThemeColor(course.id)` | 同一流程内只写一次,无重复 | +| **backend/src/services/taskService.ts** | 用户/AI 创建课程后,同样 create 再 update 写入 theme_color | 与 courseController 是**不同入口**(后台 vs 用户),不是同一逻辑改两遍 | + +**结论:主题色没有“改重复”。** 两处对应两种创建路径,各自只设置一次。 + +--- + +## 2. 思考流 (thinking flow) + +### 2.1 文案重复(同一份长文案存在两处) + +| 位置 | 内容 | +|------|------| +| **backend/src/controllers/aiCourseController.ts** | `getThinkingFlow` 里返回的长文案(多段) | +| **ios/.../AICourseModels.swift** | `AICourseConstants.defaultThinkingFlowText`(多段) | + +两处文案内容一致。当前前端只用本地常量,不再请求接口,但**后端仍保留同一份文案**。以后若改文案需要改两处,属于重复维护。 + +### 2.2 前端未使用的代码(相对“改重复”的冗余) + +| 位置 | 说明 | +|------|------| +| **AICourseService.getThinkingFlow()** | 仍调用 `/api/ai/thinking-flow` 并带 5 分钟缓存,但 **CreationHubView 已不调用**,直接用 `AICourseConstants.defaultThinkingFlowText` | +| **CreateCourseInputView** | 已标记废弃,`thinkingFlowText` 初始为 `""`,未发现调用 `getThinkingFlow` | + +即:思考流接口和缓存逻辑还在,但当前创建流程已不用,相当于死代码。 + +--- + +## 3. 总结 + +- **主题色**:没有在同一流程里重复设置;courseController 与 taskService 是不同入口,逻辑未改重复。 +- **思考流**: + - 有**文案重复**(后端 + iOS 各一份相同长文案)。 + - 有**前端冗余**:`getThinkingFlow` 与缓存未被 CreationHubView 使用。 + +若需要,我可以**只在前端**做收敛(例如删除或标注不再使用 getThinkingFlow 的调用、在注释中说明文案以后只维护 AICourseConstants 一处),**不改后端**。 diff --git a/ios/WildGrowth/COMPLETION_错位印刷_CardFrontView_审查报告.md b/ios/WildGrowth/COMPLETION_错位印刷_CardFrontView_审查报告.md new file mode 100644 index 0000000..ec9cc07 --- /dev/null +++ b/ios/WildGrowth/COMPLETION_错位印刷_CardFrontView_审查报告.md @@ -0,0 +1,86 @@ +# CardFrontView 错位印刷版 + CompletionView 微调 — 审查报告(禁止应用) + +**审查对象**:① CardFrontView 重构为「错位印刷风格」(Chromatic Aberration + 极粗宋体 + 紧凑叠字 + 胶囊标签);② CompletionView 主视图中漫射光晕透明度与卡片尺寸微调。 +**结论**:仅审查,**不应用、不修改**仓库内任何文件。 + +--- + +## 一、变更范围概览 + +| 变更项 | 当前实现 | 交付代码 | 说明 | +|--------|----------|----------|------| +| **CardFrontView** | 竖排大字 180pt bold,spacing -40,offset ±20,底部「点击开启」纯文字 + 呼吸动画 | 错位叠印(墨底 + 主层 blendMode)、220pt black,spacing -65,offset ±35/±10,底部白色胶囊「点击开启」固定 opacity 0.8 | 视觉风格从「艺术字」升级为「新中式赛博印刷风」 | +| **CompletionView 光晕** | 两枚 Circle 透明度 0.06 / 0.05 | 0.04 / 0.04 | 卡片正面白底更显眼 | +| **CompletionView 卡片容器** | .frame(width: 280, height: 400),cornerRadius 24 | .frame(width: 300, height: 440),cornerRadius 24 | 竖版藏书票感、视觉冲击更强 | + +--- + +## 二、CardFrontView 交付代码审查 + +### 2.1 设计逻辑与实现对应 + +| 设计点 | 实现方式 | 结论 | +|--------|----------|------| +| **错位叠印 (Chromatic Aberration)** | 同一 TypographyBody 画两遍:① 深紫灰墨底 offset(4,4) + blur(2);② 渐变主层 `.blendMode(.sourceAtop)` | ✅ 套色偏差感明确,有「印在纸上」的厚度 | +| **极粗宋体 (weight .black)** | 「完」「成」`.font(.system(size: 220, weight: .black, design: .serif))` | ✅ 笔画张力强,与「破纸而出」描述一致 | +| **紧凑排版 (spacing: -65)** | `VStack(spacing: -65)`,二字重叠 | ✅ 图形化图腾感,弱化可读、强化象征 | +| **标签胶囊** | 底部 HStack 包在 `Capsule().fill(Color.white).shadow(...)` 内,padding bottom 32,整体 opacity 0.8 | ✅ 不抢主视觉,又比纯文字更精致 | + +### 2.2 与当前 CardFrontView 的差异 + +- **当前**:单层渐变字 + `drawingGroup()`,底部为「arrow.up + 点击开启」纯文字 + `hintOpacity` 呼吸动画。 +- **交付**:双层(墨底 + 渐变主层)、无 `drawingGroup()`,底部为胶囊包裹的「点击开启」、无动画。 +- **命名**:交付代码仍为 `struct CardFrontView`,与当前一致,替换后 CompletionView 内 `CardFrontView(gradient: etherealGradient)` 无需改动。 + +### 2.3 需注意的点 + +| 项目 | 说明 | +|------|------| +| **shimmerOffset** | 交付代码中 `@State private var shimmerOffset: CGFloat = -0.5` 未在 body 内使用,属冗余状态,应用时可删除以免误导。 | +| **GeometryReader 与裁切** | 文字层用 `GeometryReader` + `.position(center)` 居中,外层 `.clipShape(RoundedRectangle(cornerRadius: 24))` 与当前一致,裁切行为正常。 | +| **噪点层** | `Color.gray.opacity(0.03)` 作为极淡纸张噪点,对性能影响可忽略。 | + +--- + +## 三、CompletionView 主视图微调审查 + +交付说明中的两处修改与当前代码的对应关系如下。 + +### 3.1 漫射光晕透明度 + +- **当前**: + `Color(red: 1.0, green: 0.45, blue: 0.85).opacity(0.06)` + `Color(red: 0.58, green: 0.28, blue: 0.95).opacity(0.05)` +- **交付**: + 两处均改为 `.opacity(0.04)`。 + +效果:背景光晕更弱,卡片正面白底更纯粹,与「让卡片正面的白更显眼」一致。 + +### 3.2 卡片容器尺寸与圆角 + +- **当前**: + `.frame(width: 280, height: 400)`,`.cornerRadius(24)` 已在父级或同层使用。 +- **交付**: + `.frame(width: 300, height: 440)`,圆角保持 24。 + +应用时需在 **CompletionView** 的 body 中,将包裹 `CardBackView` / `CardFrontView` 的那层 ZStack 的 `.frame(width: 280, height: 400)` 改为 `.frame(width: 300, height: 440)`;若该层未写 cornerRadius,保持现有 `.cornerRadius(24)` 即可。 + +--- + +## 四、接口与调用方影响 + +- **CardFrontView**:仍为 `CardFrontView(gradient: LinearGradient)`,仅内部实现与视觉变化,CompletionView 调用处无需改。 +- **CompletionView**:仅 body 内光晕透明度与卡片容器尺寸变化,入参、导航、按钮逻辑均不变,VerticalScreenPlayerView 等调用方无需改。 + +--- + +## 五、审查结论汇总 + +| 项目 | 结论 | +|------|------| +| **CardFrontView 错位印刷版** | 设计逻辑清晰,墨底 + sourceAtop、220pt black、spacing -65、胶囊标签均与「新中式赛博印刷风」描述一致;仅 `shimmerOffset` 未使用可删。 | +| **CompletionView 光晕** | 0.06/0.05 → 0.04/0.04 合理,正面白更突出。 | +| **CompletionView 卡片尺寸** | 280×400 → 300×440 与「竖版藏书票」描述一致。 | +| **接口与其它页面** | 无新增参数、无改动导航或 Tab,仅完结页内部视觉与尺寸调整。 | + +**未对仓库内任何文件进行修改。** diff --git a/ios/WildGrowth/DesignSystem.swift b/ios/WildGrowth/DesignSystem.swift new file mode 100644 index 0000000..e69de29 diff --git a/ios/WildGrowth/SF_SYMBOL_使用清单.md b/ios/WildGrowth/SF_SYMBOL_使用清单.md new file mode 100644 index 0000000..0e25359 --- /dev/null +++ b/ios/WildGrowth/SF_SYMBOL_使用清单.md @@ -0,0 +1,259 @@ +# SF Symbol 使用清单 + +项目中所有使用 SF Symbol 的位置及对应 symbol 名称。 + +--- + +## 一、按文件列出 + +### VerticalScreenPlayerView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 56 | `arrow.left` | 返回按钮 | +| 154 | `exclamationmark.triangle` | 错误/警告提示 | +| 209 | `book.closed` | 课程/书本占位 | +| 814 | `exclamationmark.triangle` | 错误态占位 | + +### MapView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 84 | `exclamationmark.triangle` | 错误提示 | +| 344 | `arrow.left` | 返回按钮 | +| 409 | `iconName`(动态) | 地图头水印,来自 `data.watermarkIcon` | +| 415 | `book.closed.fill` | 地图头水印兜底(无 watermarkIcon 时) | +| 597 | `lock.fill` | 节点锁定状态 | +| 607 | `checkmark` | 节点已完成 | +| 618 | `play.fill` | 节点可播放 | + +### LoginView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 51 | `xmark` | 关闭弹窗 | +| 106 | `checkmark.square.fill` / `square` | 同意协议勾选(已选/未选) | +| 176 | `applelogo` | 苹果登录按钮 | +| 364 | `arrow.left` | 返回按钮 | + +### ProfileView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 285 | `lock.circle.fill` | 锁定课程标识 | +| 393 | `course.watermarkIconName`(动态) | 课程卡片水印 | +| 421 | `course.watermarkIconName`(动态) | 课程卡片水印 | +| 530 | `exclamationmark.triangle.fill` | 错误提示 | +| 629 | `chevron.right` | 右箭头/进入 | +| 738 | `book.closed` | 空状态/占位 | +| 814 | `xmark.circle.fill` | 关闭/删除 | +| 713 | `pencil` | Label「编辑信息」 | +| 720 | `trash` | Label「删除」 | + +### Views/Profile/CyberLearningIDCard.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 174 | `square.and.pencil` | 编辑 | +| 196 | `person.fill` | 头像占位 | + +### NoteBottomSheetView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 52 | `quote.opening` | 引用/摘录 | +| 95 | `trash` | 删除 | +| 211 | `ellipsis` | 更多菜单 | + +### Views/GrowthView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 71 | `book.closed` | 课程占位 | +| 208 | `lock.circle.fill` | 锁定课程 | +| 578 | `exclamationmark.circle.fill` / `checkmark.circle.fill` | Toast 类型图标 | +| 628 | `trash` | Label「移除课程」 | +| 635 | `doc.text` | Label「查看详情」 | +| 647 | `pencil` | Label「修改课程名称」 | +| 656 | `trash` | Label「移除课程」 | + +### Views/Growth/GrowthTopBar.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 22 | `plus` | 添加按钮 | + +### ArticleRichTextView.swift(UIKit 菜单) +| 行号 | Symbol | 用途 | +|------|--------|------| +| 740 | `highlighter` | 划线 | +| 745 | `bubble.left.and.bubble.right` | 写想法 | +| 750 | `doc.on.doc` | 复制 | + +### Views/DiscoveryComponents.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 63 | `iconName`(动态) | 发现流卡片水印,来自 `item.watermarkIcon` | +| 145 | `iconName`(动态) | 信息流卡片水印,来自 `course.watermarkIcon` | + +### Views/CreationHubView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 86 | `arrow.up.circle.fill` | 上传/发布 | +| 194 | `checkmark.circle.fill` | 成功状态 | +| 219 | `xmark.circle.fill` | 失败/关闭 | +| 460 | `icon`(动态) | 列表行图标,见下方取值 | +| 473 | `chevron.right` | 右箭头 | +| 557 | `book.closed` | 空状态 | + +**CreationHubRow 的 `icon` 取值(写死在调用处):** +- `folder.fill` — 我的创作 +- `doc.text.fill` — 从文档创建 +- `arrow.triangle.2.circlepath` — 从已有课程复制 + +### DesignSystem.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 337 | `photo` | 占位图/无图 | + +### Views/PersonaSelectionView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 102 | `chevron.left` | 返回 | +| 172 | `checkmark.circle.fill` | 已选 | +| 177 | `circle` | 未选 | + +### Views/CreateCourseInputView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 158 | `doc.fill` | 文档 | +| 180 | `doc.fill` | 文档 | +| 199 | `xmark.circle.fill` | 关闭/清除 | + +### Views/GenerationProgressView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 84 | `exclamationmark.triangle.fill` | 错误 | +| 163 | `checkmark.circle.fill` | 成功 | +| 198 | `xmark` | 关闭窗口 | + +### NotebookListView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 117 | `lock.doc.fill` | 锁定笔记本 | +| 221 | `book.closed` | 空状态 | +| 193 | `pencil` | Label「编辑信息」 | +| 200 | `trash` | Label「删除」 | + +### Views/Profile/ProfileNoteListView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 16 | `note.text` | 笔记列表 | +| 113 | `book.closed` | 空状态 | + +### Views/Profile/EditProfileSheet.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 52 | `person.fill` | 头像占位 | +| 65 | `person.fill` | 头像占位 | +| 77 | `camera.fill` | 拍照/相册 | + +### Views/Profile/AvatarPicker.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 14 | `photo.stack` | 相册选择 | + +### PaywallView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 31 | `xmark.circle.fill` | 关闭 | +| 45 | `lock.open.fill` | 解锁/付费入口 | +| 133 | `checkmark.circle.fill` | 权益项勾选 | + +### ToastView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 23 | `exclamationmark.circle.fill` / `checkmark.circle.fill` / `info.circle.fill` | 按 Toast 类型(error/success/info) | + +### NoteTreeView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 136 | `square.and.pencil` | 编辑 | + +### NoteListView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 73 | `note.text` | 笔记列表 | +| 218 | `book.closed.fill` | 空状态 | +| 257 | `highlighter` | 划线 | +| 307 | `note.text.badge.plus` | 新建笔记 | + +### NoteInputView.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 35 | `quote.opening` | 引用样式 | + +### Views/NoteTree/NoteTreeRow.swift +| 行号 | Symbol | 用途 | +|------|--------|------| +| 72 | `pencil` | Label「编辑」 | +| 76 | `trash` | Label「删除」 | + +--- + +## 二、动态 Symbol 来源汇总 + +| 使用处 | 变量/属性 | 可能取值(来源) | +|--------|-----------|------------------| +| MapView 地图头水印 | `data.watermarkIcon` | 后端 API;无则兜底 `book.closed.fill` | +| DiscoveryComponents 卡片水印 | `item.watermarkIcon` / `course.watermarkIcon` | 后端 API | +| ProfileView 课程水印 | `course.watermarkIconName` | 见 CourseModels 逻辑 | + +**CourseModels.watermarkIconName 逻辑:** +- 若有 `watermarkIcon` 且非空 → 用该值 +- 否则按 `type`:`system` → `book.closed.fill`,`single` → `doc.text.fill`,`vertical_screen` → `rectangle.portrait.fill`,默认 → `doc.text.fill` + +--- + +## 三、按 Symbol 名称汇总(便于替换/统一) + +| Symbol | 出现位置(简要) | +|--------|------------------| +| `arrow.left` | VerticalScreenPlayerView, MapView, LoginView 返回 | +| `arrow.up.circle.fill` | CreationHubView 上传 | +| `arrow.triangle.2.circlepath` | CreationHubView 从课程复制 | +| `applelogo` | LoginView 苹果登录 | +| `book.closed` | VerticalScreenPlayerView, ProfileView, GrowthView, CreationHubView, NotebookListView, ProfileNoteListView 占位/空态 | +| `book.closed.fill` | MapView 水印兜底;CourseModels 中 type=system;NoteListView 空态 | +| `bubble.left.and.bubble.right` | ArticleRichTextView 写想法 | +| `camera.fill` | EditProfileSheet | +| `checkmark` | MapView 节点完成 | +| `checkmark.circle.fill` | 多处成功/已选/权益勾选 | +| `checkmark.square.fill` / `square` | LoginView 协议勾选 | +| `chevron.left` | PersonaSelectionView 返回 | +| `chevron.right` | ProfileView, CreationHubView 进入下一级 | +| `circle` | PersonaSelectionView 未选 | +| `doc.fill` | CreateCourseInputView | +| `doc.on.doc` | ArticleRichTextView 复制 | +| `doc.text` | GrowthView Label 查看详情 | +| `doc.text.fill` | CreationHubView 从文档创建;CourseModels single/默认 | +| `ellipsis` | NoteBottomSheetView 更多 | +| `exclamationmark.circle.fill` | GrowthView/ToastView 错误 | +| `exclamationmark.triangle` | VerticalScreenPlayerView, MapView 警告 | +| `exclamationmark.triangle.fill` | ProfileView, GenerationProgressView 错误 | +| `folder.fill` | CreationHubView 我的创作 | +| `highlighter` | ArticleRichTextView 划线;NoteListView 划线 | +| `info.circle.fill` | ToastView info 类型 | +| `lock.fill` | MapView 节点锁定 | +| `lock.circle.fill` | ProfileView, GrowthView 课程锁定 | +| `lock.doc.fill` | NotebookListView 笔记本锁定 | +| `lock.open.fill` | PaywallView 解锁 | +| `note.text` | ProfileNoteListView, NoteListView | +| `note.text.badge.plus` | NoteListView 新建笔记 | +| `pencil` | ProfileView, GrowthView, NotebookListView, NoteTreeRow Label 编辑 | +| `person.fill` | CyberLearningIDCard, EditProfileSheet | +| `photo` | DesignSystem 占位 | +| `photo.stack` | AvatarPicker | +| `play.fill` | MapView 节点播放 | +| `quote.opening` | NoteBottomSheetView, NoteInputView 引用 | +| `rectangle.portrait.fill` | CourseModels vertical_screen 水印 | +| `square.and.pencil` | CyberLearningIDCard, NoteTreeView 编辑 | +| `trash` | 多处 Label/按钮 删除 | +| `xmark` | LoginView, GenerationProgressView 关闭 | +| `xmark.circle.fill` | ProfileView, CreationHubView, CreateCourseInputView, PaywallView 关闭/清除 | + +--- + +*文档根据当前代码静态分析生成,若新增或修改 SF Symbol 请同步更新此清单。* diff --git a/ios/WildGrowth/WildGrowth/AICourseModels.swift b/ios/WildGrowth/WildGrowth/AICourseModels.swift new file mode 100644 index 0000000..c1a1195 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/AICourseModels.swift @@ -0,0 +1,151 @@ +import Foundation + +// MARK: - AI 课程生成相关数据模型 + +// MARK: - 导师类型枚举 +enum PersonaType: String, Codable { + case directTestLite = "direct_test_lite" // 直接测试-豆包lite + case directTestLiteOutline = "direct_test_lite_outline" // 直接测试-豆包lite-大纲 + case directTestLiteSummary = "direct_test_lite_summary" // 直接测试-豆包lite-总结 + // 文本解析的三个独立 Prompt 选项 + case textParseXiaohongshu = "text_parse_xiaohongshu" // 文本解析-小红书 + case textParseXiaolin = "text_parse_xiaolin" // 文本解析-小林说 + case textParseDouyin = "text_parse_douyin" // 文本解析-抖音 + // 续旧课的三个独立 Prompt 选项 + case continueCourseXiaohongshu = "continue_course_xiaohongshu" // 续旧课-小红书 + case continueCourseXiaolin = "continue_course_xiaolin" // 续旧课-小林说 + case continueCourseDouyin = "continue_course_douyin" // 续旧课-抖音 +} + +// MARK: - 来源类型枚举 +enum SourceType: String, Codable { + case direct = "direct" // 魔法输入栏 + case document = "document" // 传文档/贴文字 + case `continue` = "continue" // 续旧课 +} + +// MARK: - 任务状态枚举 +enum TaskStatus: String, Codable { + case pending = "pending" + case contentGenerating = "content_generating" + case completed = "completed" + case failed = "failed" + + // 计算属性:是否正在生成中 + var isGenerating: Bool { + return self == .contentGenerating + } + + // 计算属性:是否已完成 + var isCompleted: Bool { + return self == .completed + } + + // 计算属性:是否失败 + var isFailed: Bool { + return self == .failed + } + + // 计算属性:显示文本 + var displayText: String { + switch self { + case .pending: + return "准备中" + case .contentGenerating: + return "正在生成内容..." + case .completed: + return "生成完成" + case .failed: + return "生成失败" + } + } +} + +// MARK: - 创建任务响应 +struct CreateTaskResponse: Codable { + let success: Bool + let data: CreateTaskData + + struct CreateTaskData: Codable { + let taskId: String + let courseId: String + let status: String + + enum CodingKeys: String, CodingKey { + case taskId = "taskId" + case courseId = "courseId" + case status + } + } +} + +// MARK: - 任务状态响应 +struct TaskStatusResponse: Codable { + let success: Bool + let data: TaskStatusData + + struct TaskStatusData: Codable { + let taskId: String + let courseId: String + let status: String + let progress: Int + let currentStep: String? + let errorMessage: String? + let createdAt: String? + let courseTitle: String? // 当前课程标题(标题生成后由后端返回) + + enum CodingKeys: String, CodingKey { + case taskId = "taskId" + case courseId = "courseId" + case status + case progress + case currentStep = "currentStep" + case errorMessage = "errorMessage" + case createdAt = "createdAt" + case courseTitle = "courseTitle" + } + } +} + +// MARK: - 续旧课提取响应 +struct ExtractCourseResponse: Codable { + let success: Bool + let data: ExtractCourseData + + struct ExtractCourseData: Codable { + let text: String + } +} + +// MARK: - 热门主题响应 +struct PopularTopicsResponse: Codable { + let success: Bool + let data: PopularTopicsData + + struct PopularTopicsData: Codable { + let topics: [String] + } +} + +// MARK: - 思考流文本响应 +struct ThinkingFlowResponse: Codable { + let success: Bool + let data: ThinkingFlowData + + struct ThinkingFlowData: Codable { + let text: String + } +} + +// MARK: - 思考流默认文案(前端写死,避免每次进创建页多一次网络请求) +enum AICourseConstants { + static let defaultThinkingFlowText = """ + 把用户要的个性化学习内容彻底理明白,不能有半点含糊,用户要的不是那种随便拼凑的通用资料,是真正适配他学习节奏、认知水平的定制化内容,核心诉求很明确 —— 既要保证专业准确,不误导他的学习,又得通俗易懂、好落地,学完能真正用上,不能光有理论没实操,也不能太浅显没学习价值。这是最基础的,得先死死抓准,不然后续所有的思考、调整,都相当于白费功夫,哪怕多花点时间琢磨,也得把这个核心吃透,确保后续内容不跑偏,能真正满足用户的学习需求。 + 接下来,就得重新梳理内容的整体逻辑,不能瞎忙活、乱拼凑。一开始我想着,直接把相关的知识点全部整合起来,快速搭出内容框架,这样省事又高效,但转念一想,不对啊,这样做太草率了,知识点零散杂乱,没有清晰的递进关系,用户学起来只会一头雾水,抓不住重点,越学越有挫败感,根本达不到高效学习的效果,反而会浪费他的时间。得赶紧调整思路,必须遵循用户的认知规律来,先搭好清晰的整体框架,按 "基础认知 — 核心要点 — 易错提醒 — 应用落地" 的分层递进逻辑来排布,先确立好内容的主干,再一点点填充细节,每个模块之间的衔接要自然顺畅,不能出现逻辑断层,也不能有冗余的、没用的内容。刚才差点就陷入 "重速度、轻逻辑" 的误区,还好及时反应过来,赶紧拉回核心,框架稳了,内容才有支撑,用户学起来才能循序渐进、不卡壳。 + 然后,内容的准确性是底线,绝对不能含糊,这也是对用户的学习负责,不能因为图省事,就用模糊、不确定甚至错误的知识点。我得去检索权威可靠的信息来源,比如专业的行业文献、公认的学习方法论,还有经过验证的优质教学资料,不能随便找个来源就照搬,还得进行交叉比对,多方验证信息的一致性,规避单一来源可能存在的偏差和错误。刚才在核对一个核心知识点的时候,发现两个不同来源的表述有细微出入,没有急于定论,也没有随便选一个就用,而是进一步检索了更具权威性的资料,反复梳理、验证,终于找到了问题所在,修正了此前的认知偏差,确保这个知识点的表述精准无误。而且不光是核心知识点,就连一些细节补充,也都逐一核对,绝不能让任何一个不确定、不准确的内容,传递给用户,这是不可逾越的原则,哪怕多花点时间,也要保证内容的靠谱性。 + 现在,重点琢磨怎么把这些精准的内容,讲得更合适、更好懂,平衡好专业性和易懂性。既要保证内容的专业深度,能满足用户的学习需求,让他学完有收获,又要避免使用过于晦涩、生硬的专业术语,防止用户理解困难、产生抵触情绪,觉得学起来太吃力,进而放弃。我试了好几种讲解思路,一开始想采用偏学术化的讲解逻辑,把知识点讲得细致、严谨,但讲了一段就发现,太生硬、太绕了,用户大概率难以消化,甚至会越听越懵;后来又尝试简化表述,把专业术语都换成大白话,结果又导致核心深度不足,一些关键的知识点没有讲透,用户学完还是一知半解,达不到学习效果。就这样反复试、反复调整,一度陷入两难,甚至有点急躁,觉得怎么调整都达不到理想状态。后来静下心来,回归用户需求,想着用户要的是 "能学懂、能用上",不是 "看起来专业",于是决定折中调整:把复杂的知识点,拆解成具象化的小模块,每个模块先讲核心定义,再用直白、不绕弯子的表述进行通俗解读,最后搭配贴合用户日常学习场景的案例,让抽象的知识点变具体,方便用户理解和代入。刚才选定的一个案例,还不够贴切,和用户的学习场景关联不大,赶紧替换成更适配、更贴近他实际学习情况的案例,表述也再调整了一遍,去掉冗余的废话,让讲解逻辑更清晰、更顺畅,既不降低专业度,又能让用户快速理解、高效吸收。 + 整个思考过程其实并不顺利,除了讲解方式的纠结,在整合内容细节、调整模块衔接的时候,也多次陷入瓶颈。有时候觉得某个模块的内容太单薄,想补充更多细节,结果补充完又显得冗余,反而冲淡了核心;有时候觉得模块之间的衔接不够自然,调整来调整去,还是不够顺畅。试了好几种整合思路,都没能达到理想效果,一度有点着急,甚至想过要不要先暂停,再重新梳理,但转念一想,不能半途而废,用户需要的是能落地的内容,我得再坚持琢磨。于是重新静下心来,回到用户的核心需求 —— 他要的是能学懂、能用上的个性化内容,不是花里胡哨的形式,所以摒弃了所有没用的花哨设计,聚焦内容本身。把经过多方核实的权威信息,和优化后的讲解逻辑整合起来,删掉那些冗余、没用的表述,理顺每一个模块的衔接,把之前发现的所有小问题、小偏差,都逐一修正,同时再次梳理整体逻辑,检查有没有遗漏的知识点、有没有逻辑断层的地方,确保内容既符合用户的个性化需求,又具备系统性、准确性和易懂性。 + 最后,再进行全面的复盘校验,不敢有丝毫马虎。先确认用户的核心需求已经完全理解,没有任何偏差,内容的整体方向是对的;再检查内容框架,确保分层递进清晰合理,符合用户的认知规律,模块之间衔接顺畅,没有逻辑断层;然后逐一核对知识点,确认所有内容都经过权威佐证,精准无误,没有任何模糊或错误的地方;接着检查讲解方式,确认已经平衡了专业性和易懂性,表述直白不绕弯,案例适配用户的学习场景,用户能快速理解;最后检查细节,删掉所有冗余的废话,修正表述不通顺的地方,确保内容简洁、高效,没有疏漏。经过一遍又一遍的检查、调整,确认所有环节都已经达标,完全贴合用户的学习需求,能够为用户提供高效、实用、靠谱的个性化学习内容,不会让用户失望。 + 正在生成内容 + """ +} \ No newline at end of file diff --git a/ios/WildGrowth/WildGrowth/AICourseService.swift b/ios/WildGrowth/WildGrowth/AICourseService.swift new file mode 100644 index 0000000..8933cda --- /dev/null +++ b/ios/WildGrowth/WildGrowth/AICourseService.swift @@ -0,0 +1,224 @@ +import Foundation + +// MARK: - AI 课程生成服务 +// 负责所有 AI 课程生成相关的 API 调用 + +class AICourseService { + static let shared = AICourseService() + + private init() {} + + // MARK: - 缓存 + private var cachedPopularTopics: [String]? + private var topicsCacheTime: Date? + private let topicsCacheTTL: TimeInterval = 3600 // 1小时 + + private var cachedThinkingFlow: String? + private var thinkingFlowCacheTime: Date? + private let thinkingFlowTTL: TimeInterval = 300 // 5 分钟,兼顾「部署后能较快看到新文案」和「不每次进创建页都请求」 + + // MARK: - 创建任务 + + /// 统一创建课程接口 + /// - Parameters: + /// - sourceText: 原始文本 + /// - sourceType: 来源类型 + /// - persona: 导师类型 + /// - Returns: 任务ID和课程ID + func createCourse( + sourceText: String, + sourceType: SourceType, + persona: PersonaType, + parentCourseId: String? = nil + ) async throws -> (taskId: String, courseId: String) { + // ✅ 使用新的统一创建接口,支持 sourceType 和 persona + let endpoint = "/api/ai/courses/create" + var body: [String: Any] = [ + "sourceText": sourceText, + "sourceType": sourceType.rawValue, + "persona": persona.rawValue + ] + // 续旧课时传 parentCourseId + if let parentCourseId = parentCourseId { + body["parentCourseId"] = parentCourseId + } + + let response: CreateTaskResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "POST", + body: body, + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("创建课程失败") + } + + return (response.data.taskId, response.data.courseId) + } + + /// 上传文档并解析文本(独立流程) + /// - Parameters: + /// - fileURL: 文档文件 URL + /// - fileName: 文件名 + /// - mimeType: MIME 类型 + /// - Returns: 解析后的文本内容 + func uploadDocument( + fileURL: URL, + fileName: String, + mimeType: String + ) async throws -> String { + let endpoint = "/api/upload/document" + + struct DocumentUploadResponse: Decodable { + let success: Bool + struct Data: Decodable { + let text: String + let originalLength: Int + let filename: String? + let truncated: Bool? + } + let data: Data + } + + let response: DocumentUploadResponse = try await APIClient.shared.uploadFile( + endpoint: endpoint, + fileURL: fileURL, + fileName: fileName, + mimeType: mimeType, + fieldName: "document", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("文档上传并解析失败") + } + + return response.data.text + } + + // MARK: - 获取任务状态 + + /// 获取任务状态 + /// - Parameter taskId: 任务ID + /// - Returns: 任务状态信息 + func getTaskStatus(taskId: String) async throws -> TaskStatusResponse.TaskStatusData { + let endpoint = "/api/ai/courses/\(taskId)/status" + + let response: TaskStatusResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取任务状态失败") + } + + return response.data + } + + // MARK: - 续旧课提取 + + /// 提取课程内容(用于续旧课) + /// - Parameter courseId: 课程ID + /// - Returns: 提取的文本内容 + func extractCourseContent(courseId: String) async throws -> String { + let endpoint = "/api/ai/courses/\(courseId)/extract" + + let response: ExtractCourseResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "POST", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("提取课程内容失败") + } + + return response.data.text + } + + // MARK: - 热门主题 + + /// 获取热门主题(带缓存) + /// - Parameter forceRefresh: 是否强制刷新 + /// - Returns: 热门主题列表 + func getPopularTopics(forceRefresh: Bool = false) async throws -> [String] { + // 检查缓存 + if !forceRefresh, + let cached = cachedPopularTopics, + let cacheTime = topicsCacheTime, + Date().timeIntervalSince(cacheTime) < topicsCacheTTL { + return cached + } + + let endpoint = "/api/ai/topics/popular" + let response: PopularTopicsResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取热门主题失败") + } + + // 更新缓存 + cachedPopularTopics = response.data.topics + topicsCacheTime = Date() + + return response.data.topics + } + + // MARK: - 思考流文本 + + /// 获取思考流文本(带缓存,5 分钟 TTL) + /// - Parameter forceRefresh: 是否强制刷新(忽略 TTL) + /// - Returns: 思考流文本 + func getThinkingFlow(forceRefresh: Bool = false) async throws -> String { + let now = Date() + if !forceRefresh, + let cached = cachedThinkingFlow, + let cachedAt = thinkingFlowCacheTime, + now.timeIntervalSince(cachedAt) < thinkingFlowTTL { + return cached + } + + let endpoint = "/api/ai/thinking-flow" + + let response: ThinkingFlowResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取思考流文本失败") + } + + cachedThinkingFlow = response.data.text + thinkingFlowCacheTime = Date() + + return response.data.text + } + + // MARK: - 获取用户已加入的课程 + + /// 获取用户已加入的课程列表(用于续旧课) + /// 注意:续旧课应该显示用户已加入的课程,而不是创建的课程 + /// - Returns: 课程列表 + func getUserCreatedCourses() async throws -> [Course] { + // ✅ 修复:使用 /api/my-courses 获取用户已加入的课程(通过 UserCourse 表) + let endpoint = "/api/my-courses" + + let response: CourseListResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "GET", + requiresAuth: true + ) + + return response.data.courses + } + +} diff --git a/ios/WildGrowth/WildGrowth/APIClient.swift b/ios/WildGrowth/WildGrowth/APIClient.swift new file mode 100644 index 0000000..1c56008 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/APIClient.swift @@ -0,0 +1,331 @@ +import Foundation + +class APIClient { + static let shared = APIClient() + + #if DEBUG + // MARK: - 开发环境配置 (Debug) + + var baseURL: String { + // 【⚠️ 生产环境调试开关】 + // true = Debug 模式下连接线上服务器 (用于上线前验证 HTTPS 和数据) + // false = Debug 模式下连接本地 localhost (用于日常开发) + let useProduction = false // <--- 当前已开启,方便你马上测试! + + if useProduction { + return "https://api.muststudy.xin" + } + + #if targetEnvironment(simulator) + // 模拟器:访问电脑本机 + return "http://localhost:3000" + #else + // 真机调试 (连接本地后端): + // ⚠️ 如果切换回 false,请确保这里是你电脑的实际局域网 IP + return "http://192.168.1.100:3000" + #endif + } + + #else + // MARK: - 生产环境配置 (Release/TestFlight) + + /// 生产环境固定使用线上域名,打包 / TestFlight 必走此处 + var baseURL: String { "https://api.muststudy.xin" } + + #endif + + // ✅ 复用 URLSession,避免每次请求都创建新的 session + private let session: URLSession + + private init() { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 30 + // ✅ 禁用缓存,确保每次都获取最新数据 + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: configuration) + } + + // MARK: - 调试方法:打印当前连接的服务器 + + /// 获取当前连接的服务器地址(用于调试) + func getCurrentServer() -> String { + return baseURL + } + + func request( + endpoint: String, + method: String = "GET", + body: [String: Any]? = nil, + requiresAuth: Bool = false + ) async throws -> T { + let fullURL = "\(baseURL)\(endpoint)" + + // ✅ 修复:添加详细日志,特别是对于 select-mode 接口 + if endpoint.contains("select-mode") { + print("🌐 [APIClient] ========== 模式选择 API 调用 ==========") + print("🌐 [APIClient] URL: \(fullURL)") + print("🌐 [APIClient] Method: \(method)") + print("🌐 [APIClient] Body: \(body ?? [:])") + print("🌐 [APIClient] RequiresAuth: \(requiresAuth)") + if requiresAuth { + let hasToken = TokenManager.shared.getToken() != nil + print("🌐 [APIClient] HasToken: \(hasToken)") + } + } + + guard let url = URL(string: fullURL) else { + print("❌ [APIClient] 无效的 URL: \(fullURL)") + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.cachePolicy = .reloadIgnoringLocalCacheData // ✅ 禁用缓存,确保获取最新数据 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // ✨ 调试:打印 requiresAuth 和 Token 状态 + if requiresAuth, let token = TokenManager.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + if endpoint.contains("select-mode") { + print("🌐 [APIClient] Authorization header 已设置") + } + } else if requiresAuth { + print("❌ [APIClient] 需要认证但 Token 不存在") + } + + if let body = body { + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + if endpoint.contains("select-mode") { + if let bodyData = request.httpBody, let bodyString = String(data: bodyData, encoding: .utf8) { + print("🌐 [APIClient] Request Body: \(bodyString)") + } + } + } + + // ✅ 连接类错误重试一次(真机/TestFlight 首次启动时网络可能尚未就绪) + let retryableCodes: [URLError.Code] = [.cannotConnectToHost, .cannotFindHost, .networkConnectionLost, .timedOut, .dnsLookupFailed, .secureConnectionFailed, .notConnectedToInternet, .internationalRoamingOff] + + for attempt in 1...2 { + do { + if endpoint.contains("select-mode") { + print("🌐 [APIClient] 开始发送请求...") + } + let (data, response) = try await session.data(for: request) + + if endpoint.contains("select-mode") { + print("🌐 [APIClient] 收到响应: statusCode=\((response as? HTTPURLResponse)?.statusCode ?? -1)") + } + + if let httpResponse = response as? HTTPURLResponse { + if endpoint.contains("select-mode") { + print("🌐 [APIClient] HTTP Status: \(httpResponse.statusCode)") + if let responseString = String(data: data, encoding: .utf8) { + print("🌐 [APIClient] Response Body: \(responseString.prefix(500))") + } + } + + if httpResponse.statusCode == 401 { + print("❌ [APIClient] 401 未授权错误: endpoint=\(endpoint)") + throw APIError.unauthorized + } + + if !(200...299).contains(httpResponse.statusCode) { + if httpResponse.statusCode == 202 { + if let errResp = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + throw APIError.serverError(errResp.error.message) + } + throw APIError.serverError("课程内容正在生成中,请稍候再试") + } + if httpResponse.statusCode == 413 { + if let errResp = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + #if DEBUG + print("❌ [API] 413 请求体过大: \(errResp.error.message)") + #endif + throw APIError.serverError(errResp.error.message) + } + throw APIError.serverError("内容过长,请缩短后重试(建议不超过10万字)") + } + if let errResp = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + #if DEBUG + print("❌ [API] 服务器错误 (\(httpResponse.statusCode)): \(errResp.error.message)") + #endif + throw APIError.serverError(errResp.error.message) + } + #if DEBUG + if let responseString = String(data: data, encoding: .utf8) { + print("❌ [API] 无法解析的错误响应: \(responseString)") + } + #endif + throw APIError.serverError("服务器错误 (\(httpResponse.statusCode))") + } + } + + #if DEBUG + if endpoint.contains("/api/courses") && !endpoint.contains("/map") { + if let jsonString = String(data: data, encoding: .utf8) { + print("\n========== 📥 后端返回的原始 JSON ==========") + print(jsonString) + print("==========================================\n") + } + } + #endif + + print("🌐 [API] 开始解析 JSON 响应") + let result = try JSONDecoder().decode(T.self, from: data) + print("🌐 [API] JSON 解析成功") + return result + } catch let decodingError as DecodingError { + print("❌ JSON Decoding Error: Endpoint=\(endpoint), Error=\(decodingError)") + throw APIError.decodingError + } catch let urlError as URLError { + let shouldRetry = retryableCodes.contains(urlError.code) && attempt == 1 + if shouldRetry { + print("❌ [APIClient] 连接失败(\(urlError.code.rawValue)),2 秒后重试: \(endpoint)") + try? await Task.sleep(nanoseconds: 2_000_000_000) + continue + } + print("❌ [APIClient] 网络错误: \(urlError.localizedDescription)") + if endpoint.contains("select-mode") { + print("❌ [APIClient] URL Error Code: \(urlError.code.rawValue)") + if urlError.code == .timedOut { print("❌ [APIClient] 请求超时(30秒)") } + } + throw APIError.unknown(urlError) + } catch { + print("❌ [APIClient] Unknown Error: \(error)") + throw APIError.unknown(error) + } + } + throw APIError.noData + } + + // MARK: - 文件上传 + + /// 上传文件(multipart/form-data) + /// - Parameters: + /// - endpoint: API 端点 + /// - fileURL: 文件 URL + /// - fileName: 文件名 + /// - mimeType: MIME 类型 + /// - fieldName: 表单字段名(默认为 "document") + /// - requiresAuth: 是否需要认证 + /// - Returns: 解码后的响应 + func uploadFile( + endpoint: String, + fileURL: URL, + fileName: String, + mimeType: String, + fieldName: String = "document", + requiresAuth: Bool = false + ) async throws -> T { + // ✅ 文件读取在后台线程执行,不阻塞UI + let fullURL = "\(baseURL)\(endpoint)" + + guard let url = URL(string: fullURL) else { + print("❌ [APIClient] 无效的 URL: \(fullURL)") + throw APIError.invalidURL + } + + // 创建 multipart/form-data 请求 + var request = URLRequest(url: url) + request.httpMethod = "POST" + + // 设置认证头 + if requiresAuth, let token = TokenManager.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + // 创建 multipart body + let boundary = UUID().uuidString + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + + // 添加文件数据 + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + + // 读取文件数据(异步,不阻塞UI) + let fileData: Data + do { + // 使用后台队列读取文件,避免阻塞主线程 + fileData = try await Task.detached(priority: .userInitiated) { + try Data(contentsOf: fileURL) + }.value + body.append(fileData) + } catch { + print("❌ [APIClient] 读取文件失败: \(error.localizedDescription)") + throw APIError.unknown(error) + } + + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + print("🌐 [APIClient] 上传文件: \(fileName), 大小: \(body.count) bytes") + + do { + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 401 { + print("❌ [APIClient] 401 未授权错误: endpoint=\(endpoint)") + throw APIError.unauthorized + } + + if !(200...299).contains(httpResponse.statusCode) { + // 尝试解析错误信息 + if let errResp = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + print("❌ [API] 服务器错误 (\(httpResponse.statusCode)): \(errResp.error.message)") + throw APIError.serverError(errResp.error.message) + } + throw APIError.serverError("服务器错误 (\(httpResponse.statusCode))") + } + } + + print("🌐 [API] 开始解析 JSON 响应") + let result = try JSONDecoder().decode(T.self, from: data) + print("🌐 [API] JSON 解析成功") + return result + } catch let decodingError as DecodingError { + print("❌ JSON Decoding Error: \(decodingError)") + throw APIError.decodingError + } catch let urlError as URLError { + print("❌ [APIClient] 网络错误: \(urlError.localizedDescription)") + throw APIError.unknown(urlError) + } catch { + print("❌ [APIClient] Unknown Error: \(error)") + throw APIError.unknown(error) + } + } + + // MARK: - 图片URL处理 + + /// 构建完整图片URL + /// - Parameter imageUrl: 相对路径(如 "images/xxx.png")或完整URL + /// - Returns: 完整的URL,如果输入无效则返回nil + func getImageURL(_ imageUrl: String?) -> URL? { + guard let imageUrl = imageUrl, !imageUrl.isEmpty else { + return nil + } + + // 如果已经是完整URL,直接返回 + if imageUrl.hasPrefix("http://") || imageUrl.hasPrefix("https://") { + return URL(string: imageUrl) + } + + // 处理相对路径:移除开头的 "/"(如果有),避免拼接成 "https://api.muststudy.xin//images/xxx.png" + let cleanPath = imageUrl.hasPrefix("/") ? String(imageUrl.dropFirst()) : imageUrl + + // 拼接相对路径 + let fullURL = "\(baseURL)/\(cleanPath)" + return URL(string: fullURL) + } +} + +// 辅助结构:后端错误响应 +struct ErrorResponse: Decodable { + struct ErrorDetail: Decodable { let message: String } + let error: ErrorDetail +} diff --git a/ios/WildGrowth/WildGrowth/APIError.swift b/ios/WildGrowth/WildGrowth/APIError.swift new file mode 100644 index 0000000..631eba6 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/APIError.swift @@ -0,0 +1,25 @@ +import Foundation + +enum APIError: LocalizedError { + case invalidURL + case noData + case decodingError + case serverError(String) + case unauthorized + case unknown(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: return "无效的 URL" + case .noData: return "服务器未返回数据" + case .decodingError: return "数据解析失败" + case .serverError(let msg): return msg + case .unauthorized: return "登录已过期,请重新登录" + case .unknown(let error): return error.localizedDescription + } + } +} + + + + diff --git a/ios/WildGrowth/WildGrowth/AnalyticsManager.swift b/ios/WildGrowth/WildGrowth/AnalyticsManager.swift new file mode 100644 index 0000000..f81e9f1 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/AnalyticsManager.swift @@ -0,0 +1,276 @@ +import Foundation +import UIKit + +// MARK: - V1.0 轻量埋点管理器 + +final class AnalyticsManager { + static let shared = AnalyticsManager() + + // MARK: - 配置 + private let flushInterval: TimeInterval = 30 // 30 秒自动 flush + private let flushThreshold: Int = 20 // 队列满 20 条自动 flush + private let maxRetryEvents: Int = 500 // 离线缓存最多保留 500 条 + + // MARK: - 内部状态 + private var eventQueue: [[String: Any]] = [] // 内存事件队列 + private let queue = DispatchQueue(label: "com.wildgrowth.analytics", qos: .utility) + private var flushTimer: Timer? + private var sessionId: String = UUID().uuidString + private var sessionStartTime: Date = Date() + + // MARK: - 设备上下文(启动时采集一次) + private lazy var deviceId: String = { + // 优先使用持久化的 deviceId,否则生成新的 + if let stored = UserDefaults.standard.string(forKey: "analytics_device_id") { + return stored + } + let id = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + UserDefaults.standard.set(id, forKey: "analytics_device_id") + return id + }() + + private lazy var appVersion: String = { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + }() + + private lazy var osVersion: String = { + UIDevice.current.systemVersion + }() + + private lazy var deviceModel: String = { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { id, element in + guard let value = element.value as? Int8, value != 0 else { return id } + return id + String(UnicodeScalar(UInt8(value))) + } + return identifier + }() + + // MARK: - Init + + private init() { + // 加载离线缓存的事件 + loadCachedEvents() + // 启动定时 flush + startFlushTimer() + // 监听 App 生命周期 + setupLifecycleObservers() + } + + // MARK: - 公开 API + + /// 记录一个事件 + /// - Parameters: + /// - event: 事件名称(如 "app_launch", "discovery_view") + /// - properties: 自定义属性(可选) + func track(_ event: String, properties: [String: Any]? = nil) { + queue.async { [weak self] in + guard let self = self else { return } + + let eventData: [String: Any] = [ + "eventName": event, + "timestamp": ISO8601DateFormatter().string(from: Date()), + "sessionId": self.sessionId, + "properties": properties ?? [:] + ] + + self.eventQueue.append(eventData) + + #if DEBUG + print("📊 [Analytics] track: \(event) | queue=\(self.eventQueue.count)") + #endif + + // 达到阈值自动 flush + if self.eventQueue.count >= self.flushThreshold { + self.flushInternal() + } + } + } + + /// 手动触发上报(进后台时调用) + func flush() { + queue.async { [weak self] in + self?.flushInternal() + } + } + + /// 开始新会话(App 进入前台时调用) + func startNewSession() { + queue.async { [weak self] in + guard let self = self else { return } + self.sessionId = UUID().uuidString + self.sessionStartTime = Date() + #if DEBUG + print("📊 [Analytics] 新会话: \(self.sessionId)") + #endif + } + } + + // MARK: - 内部逻辑 + + private func flushInternal() { + // 已在 self.queue 中执行,无需再 dispatch + guard !eventQueue.isEmpty else { return } + + let eventsToSend = eventQueue + eventQueue.removeAll() + + #if DEBUG + print("📊 [Analytics] flush: 发送 \(eventsToSend.count) 条事件") + #endif + + // 构建请求体 + let context: [String: Any] = [ + "deviceId": deviceId, + "userId": UserManager.shared.currentUser?.id as Any, + "sessionId": sessionId, + "appVersion": appVersion, + "osVersion": osVersion, + "deviceModel": deviceModel, + "networkType": getNetworkType() + ] + + let body: [String: Any] = [ + "events": eventsToSend, + "context": context + ] + + // 发送网络请求(不依赖 APIClient,独立轻量实现) + sendEvents(body: body, events: eventsToSend) + } + + private func sendEvents(body: [String: Any], events: [[String: Any]]) { + let baseURL = APIClient.shared.baseURL + guard let url = URL(string: "\(baseURL)/api/analytics/events") else { + // URL 无效,缓存事件 + cacheEvents(events) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 10 // 埋点请求 10 秒超时,不拖累体验 + + guard let httpBody = try? JSONSerialization.data(withJSONObject: body) else { + cacheEvents(events) + return + } + request.httpBody = httpBody + + // 使用独立的 URLSession(不复用 APIClient 的 session) + URLSession.shared.dataTask(with: request) { [weak self] _, response, error in + if let error = error { + #if DEBUG + print("📊 [Analytics] ❌ 上报失败: \(error.localizedDescription)") + #endif + // 网络失败,缓存到本地 + self?.cacheEvents(events) + return + } + + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { + #if DEBUG + print("📊 [Analytics] ✅ 上报成功: \(events.count) 条") + #endif + } else { + self?.cacheEvents(events) + } + }.resume() + } + + // MARK: - 离线缓存 + + private var cacheFileURL: URL { + let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return dir.appendingPathComponent("analytics_cache.json") + } + + private func cacheEvents(_ events: [[String: Any]]) { + queue.async { [weak self] in + guard let self = self else { return } + // 加载现有缓存 + var cached = self.loadCachedEventsFromDisk() + cached.append(contentsOf: events) + // 限制缓存大小 + if cached.count > self.maxRetryEvents { + cached = Array(cached.suffix(self.maxRetryEvents)) + } + // 写入文件 + if let data = try? JSONSerialization.data(withJSONObject: cached) { + try? data.write(to: self.cacheFileURL) + } + #if DEBUG + print("📊 [Analytics] 缓存 \(events.count) 条事件(总缓存: \(cached.count))") + #endif + } + } + + private func loadCachedEvents() { + let cached = loadCachedEventsFromDisk() + if !cached.isEmpty { + eventQueue.append(contentsOf: cached) + // 清空缓存文件 + try? FileManager.default.removeItem(at: cacheFileURL) + #if DEBUG + print("📊 [Analytics] 加载 \(cached.count) 条离线缓存事件") + #endif + } + } + + private func loadCachedEventsFromDisk() -> [[String: Any]] { + guard let data = try? Data(contentsOf: cacheFileURL), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [] + } + return array + } + + // MARK: - 定时器 + + private func startFlushTimer() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.flushTimer = Timer.scheduledTimer(withTimeInterval: self.flushInterval, repeats: true) { [weak self] _ in + self?.flush() + } + } + } + + // MARK: - 生命周期监听 + + private func setupLifecycleObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + @objc private func appWillResignActive() { + track("app_enter_background") + flush() + } + + @objc private func appDidBecomeActive() { + startNewSession() + track("app_enter_foreground") + } + + // MARK: - 网络类型检测(轻量实现) + + private func getNetworkType() -> String { + // 简化实现:不引入 NWPathMonitor 依赖,直接返回 unknown + // 后续如需细分 WiFi/Cellular 可扩展 + return "unknown" + } +} diff --git a/ios/WildGrowth/WildGrowth/ArticleRichTextView.swift b/ios/WildGrowth/WildGrowth/ArticleRichTextView.swift new file mode 100644 index 0000000..505a490 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/ArticleRichTextView.swift @@ -0,0 +1,794 @@ +import UIKit + +/// 核心阅读器视图 (Based on UITextView) +/// 职责:渲染全量富文本、处理原生文本选择、弹出自定义菜单、渲染笔记高亮 +/// 架构级优化:直接操作 textStorage,避免重新赋值 attributedText 导致的滚动位置重置 +class ArticleRichTextView: UITextView { + + // MARK: - Callbacks + /// 划线回调 (返回全局 NSRange) + var onHighlightAction: ((NSRange) -> Void)? + /// 写想法回调 (返回全局 NSRange) + var onThoughtAction: ((NSRange) -> Void)? + /// 笔记点击回调 (返回点击的笔记) + var onNoteTap: ((Note) -> Void)? + /// 滚动进度回调 (返回当前进度 0.0-1.0) + var onScrollProgress: ((Float) -> Void)? + /// 复制成功回调(选中文字点复制后调用,用于展示「已复制」Toast) + var onCopySuccess: (() -> Void)? + + // MARK: - Data State + // 缓存当前的笔记 ID 列表,用于快速判断是否需要更新 + private var currentNoteIds: Set = [] + + // ✅ 保留 notes 变量用于点击检测 + private var notes: [Note] = [] + + // ⚡️ 关键:缓存当前内容字符串的 Hash,避免不必要的更新 + private var lastContentStringHash: Int = 0 + + // ⚡️ 关键:保留 TextKit 1 组件的引用,防止被释放 + private var textKit1Storage: NSTextStorage? + private var textKit1LayoutManager: NSLayoutManager? + + // ✅ 滚动进度更新防抖(加强防抖,避免频繁调用) + private var lastProgressUpdateTime: TimeInterval = 0 + private var lastReportedProgress: Float = -1.0 // 记录上次报告的进度 + private let progressUpdateInterval: TimeInterval = 0.2 // 200ms 防抖 + + // MARK: - Init (核心修复:强制锁定 TextKit 1) + + /// 创建 TextKit 1 组件的辅助方法 + private func createTextKit1Components() -> (NSTextStorage, NSLayoutManager, NSTextContainer) { + let storage = NSTextStorage() + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + + // 组装 TextKit 1 堆栈(顺序很重要) + storage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(container) + + // ⚡️ 关键:确保 container 已经关联到 layoutManager + // layoutManager.addTextContainer(container) 会自动设置 container.layoutManager + + // 配置容器 + container.widthTracksTextView = true + container.heightTracksTextView = true + container.lineFragmentPadding = 0 // 移除默认的左右留白 + + // ⚡️ 关键:保留引用,防止被释放 + self.textKit1Storage = storage + self.textKit1LayoutManager = layoutManager + + return (storage, layoutManager, container) + } + + /// 无参初始化器:强制使用 TextKit 1 + init() { + // ⚡️ 关键:先创建组件,再调用 super.init + let storage = NSTextStorage() + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + + // 组装 TextKit 1 堆栈 + storage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(container) + + // 配置容器 + container.widthTracksTextView = true + container.heightTracksTextView = true + container.lineFragmentPadding = 0 + + // 调用父类初始化 + super.init(frame: .zero, textContainer: container) + + // ⚡️ 关键:初始化后保留引用 + self.textKit1Storage = storage + self.textKit1LayoutManager = layoutManager + + setupConfiguration() + } + + /// 带参数的初始化器:也强制使用 TextKit 1 + override init(frame: CGRect, textContainer: NSTextContainer?) { + // ✅ 修正:如果传入了 textContainer,使用它;否则创建新的 TextKit 1 组件 + let finalContainer: NSTextContainer + if let providedContainer = textContainer, providedContainer.layoutManager != nil { + // 如果传入的 container 已经有 layoutManager,直接使用 + finalContainer = providedContainer + } else { + // 创建新的 TextKit 1 组件 + let storage = NSTextStorage() + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + + storage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(container) + + container.widthTracksTextView = true + container.heightTracksTextView = true + container.lineFragmentPadding = 0 + + self.textKit1Storage = storage + self.textKit1LayoutManager = layoutManager + + finalContainer = container + } + + super.init(frame: frame, textContainer: finalContainer) + setupConfiguration() + } + + /// Storyboard 初始化器:也强制使用 TextKit 1 + required init?(coder: NSCoder) { + // ✅ 修正:即使从 Storyboard 加载,也创建 TextKit 1 组件 + let storage = NSTextStorage() + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + + storage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(container) + + container.widthTracksTextView = true + container.heightTracksTextView = true + container.lineFragmentPadding = 0 + + super.init(frame: .zero, textContainer: container) + + self.textKit1Storage = storage + self.textKit1LayoutManager = layoutManager + + setupConfiguration() + } + + // MARK: - Configuration + private func setupConfiguration() { + // 1. 基础属性 + self.isEditable = false // 只读 + self.isSelectable = true // 允许选择 + self.isScrollEnabled = true // 允许滚动 (方案B的核心优势) + self.showsVerticalScrollIndicator = false + self.backgroundColor = .clear // 由外部容器控制背景 + + // ✅ 修复单屏滚动问题:强制启用滚动,即使内容高度小于可见区域 + // UITextView 默认情况下,如果内容高度 <= 可见高度,会自动禁用滚动 + // 我们需要强制保持滚动启用状态 + self.alwaysBounceVertical = true // 允许弹性滚动 + self.bounces = true // 启用弹性效果 + + // 2. 内边距配置 (关键:与设计稿对齐) + // 这里的 Inset 相当于页面左右的 Padding + // ✅ 底部内边距设为 140pt,为外部悬浮按钮留出空间(按钮高度 54pt + 阴影 + 底部间距 20pt + 额外留白) + self.textContainerInset = UIEdgeInsets(top: 0, left: 24, bottom: 140, right: 24) + + // 3. 移除左右留白,确保排版精准 + self.textContainer.lineFragmentPadding = 0 + + // 4. 设置代理 (用于处理 iOS 16+ 菜单和滚动监听) + self.delegate = self + + // 5. 交互行为优化 + // 禁用链接检测等可能干扰阅读的特性,除非有明确需求 + self.dataDetectorTypes = [] + + // 6. 添加点击手势检测笔记 + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + tapGesture.cancelsTouchesInView = false // ✅ 避免手势冲突 + addGestureRecognizer(tapGesture) + } + + // MARK: - 拦截系统菜单 + /// 只允许"复制"按钮,屏蔽其他系统菜单项(如"查询"、"翻译"、"分享"等) + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + // 只允许 "复制" 存在,其他一律屏蔽 + if action == #selector(copy(_:)) { + return true + } + return false + } + + // MARK: - Layout & Scroll Detection + + private var hasCheckedSingleScreen = false + + override func layoutSubviews() { + super.layoutSubviews() + + // ✅ 修复单屏滚动问题:强制保持滚动启用 + // 即使内容高度 <= 可见高度,也要保持滚动能力(用于弹性滚动和手势) + if !isScrollEnabled { + isScrollEnabled = true + } + + // ✅ 检测"一屏内容":如果内容高度 <= 可见高度,立即标记为完成 + // 只在首次布局完成后检查一次,避免重复触发 + if !hasCheckedSingleScreen && bounds.height > 0 && textStorage.length > 0 { + // 使用 layoutManager 计算实际内容高度(更准确) + let usedRect = layoutManager.usedRect(for: textContainer) + let contentHeight = usedRect.height + textContainerInset.top + textContainerInset.bottom + let visibleHeight = bounds.height - contentInset.top - contentInset.bottom + + if contentHeight <= visibleHeight { + // 内容可以在一屏内显示完,立即触发 progress = 1.0 + hasCheckedSingleScreen = true + DispatchQueue.main.async { [weak self] in + self?.onScrollProgress?(1.0) + } + } + } + } + + // MARK: - Public API (核心重构:架构级优化) + + /// 更新内容和笔记 + /// - Parameters: + /// - content: 由 ContentBlockBuilder 生成的完整富文本 + /// - notes: 笔记列表 + func update(content: NSAttributedString, notes: [Note]) { + // ✅ 修正:空内容处理 + guard content.length > 0 else { + if self.attributedText.length > 0 { + self.attributedText = NSAttributedString() + self.notes = [] + self.currentNoteIds = [] + self.lastContentStringHash = 0 + } + return + } + + // ⚡️ 关键优化 1:快速检查内容是否真的变化了 + let contentStringHash = content.string.hashValue + let notesHash = Set(notes.map { "\($0.id)_\($0.updatedAt.timeIntervalSince1970)" }) + + // 如果内容和笔记都没变化,直接返回,避免任何操作 + if contentStringHash == lastContentStringHash && notesHash == currentNoteIds { + return + } + + // 1. 处理正文 (Heavy Operation) + // 只有当正文内容发生实质变化时(比如切课),才重置 attributedText + // 这保证了 ScrollView 的偏移量永远不会因为 SwiftUI 的 view update 而重置 + let contentChanged = contentStringHash != lastContentStringHash + if contentChanged { + self.attributedText = content + self.lastContentStringHash = contentStringHash + // 正文变了,笔记肯定要重绘,清空缓存 + self.currentNoteIds = [] + // ✅ 重置"一屏内容"检测标志 + self.hasCheckedSingleScreen = false + } + + // ✅ 修正:更新 notes 变量(用于点击检测) + self.notes = notes + + // 2. 处理笔记 (Light Operation) + // 检查笔记是否有变化。如果完全一样,直接跳过,0消耗。 + if notesHash != currentNoteIds { + // ⚡️ 关键:在主线程同步更新,但确保 textStorage 已准备好 + // 如果 textStorage 还没准备好(比如刚设置了 attributedText),等待一帧 + if textStorage.length == 0 && contentChanged { + // 内容刚变化,textStorage 可能还没同步,延迟一帧 + DispatchQueue.main.async { [weak self] in + guard let self = self, self.textStorage.length > 0 else { return } + let currentNotesHash = Set(self.notes.map { "\($0.id)_\($0.updatedAt.timeIntervalSince1970)" }) + if currentNotesHash == notesHash { + self.updateHighlights(notes: self.notes) + self.currentNoteIds = notesHash + } + } + } else { + // textStorage 已准备好,直接更新 + updateHighlights(notes: notes) + currentNoteIds = notesHash + } + } + } + + // MARK: - Highlight Engine (外科手术式更新) + + /// 直接操作 textStorage 更新高亮样式,避免重新赋值 attributedText + private func updateHighlights(notes: [Note]) { + // ✅ 修正:确保 textStorage 存在且可操作 + guard textStorage.length > 0 else { + print("⚠️ [ArticleRichTextView] textStorage is empty, length: \(textStorage.length)") + return + } + + // ⚡️ 关键:直接操作 textStorage,不使用 beginEditing/endEditing + // 因为 beginEditing/endEditing 在某些情况下可能导致死锁 + // 直接操作更安全,且对于简单的属性更新,性能影响可忽略 + + // ⚡️ 关键步骤 1:清洗旧样式 (Clean Slate) + // 我们只移除我们自己添加的样式 (背景色、下划线),保留字体、段落间距等 Base 样式 + let fullRange = NSRange(location: 0, length: textStorage.length) + textStorage.removeAttribute(.backgroundColor, range: fullRange) + textStorage.removeAttribute(.underlineStyle, range: fullRange) + textStorage.removeAttribute(.underlineColor, range: fullRange) + + // ⚡️ 关键步骤 2:分离用户笔记和系统笔记,处理重叠 + // ✅ 重叠逻辑:保留所有笔记的范围,对于重叠部分在应用样式时按优先级选择 + // ✅ 优先级规则(仅适用于重合部分): + // 1. 有用户划线笔记时,重合部分优先显示蓝色背景,不显示想法笔记线段 + // 2. 无用户划线笔记时,重合部分用户想法笔记优先于系统想法笔记 + // 3. 系统想法笔记使用下划线样式(和用户想法笔记一样),但颜色不同(深灰色) + let userNotes = notes.filter { !$0.isSystemNote } + let systemNotes = notes.filter { $0.isSystemNote } + + // 分离用户笔记类型 + let userHighlightNotes = userNotes.filter { $0.type == .highlight } + let userThoughtNotes = userNotes.filter { $0.type == .thought } + + // 先处理用户划线笔记,按范围分组 + var userHighlightRangeToNotes: [NSRange: [Note]] = [:] + for note in userHighlightNotes { + guard let startIndex = note.startIndex, let length = note.length else { + continue + } + let noteRange = NSRange(location: startIndex, length: length) + if NSMaxRange(noteRange) > textStorage.length { + print("⚠️ [ArticleRichTextView] User highlight note range out of bounds: \(noteRange)") + continue + } + var found = false + for (existingRange, existingNotes) in userHighlightRangeToNotes { + if NSEqualRanges(noteRange, existingRange) { + userHighlightRangeToNotes[existingRange] = existingNotes + [note] + found = true + break + } + } + if !found { + userHighlightRangeToNotes[noteRange] = [note] + } + } + + // 处理用户想法笔记,排除与用户划线笔记重叠的部分(只排除重叠部分,保留非重叠部分) + var userThoughtRangeToNotes: [NSRange: [Note]] = [:] + for note in userThoughtNotes { + guard let startIndex = note.startIndex, let length = note.length else { + continue + } + let noteRange = NSRange(location: startIndex, length: length) + if NSMaxRange(noteRange) > textStorage.length { + print("⚠️ [ArticleRichTextView] User thought note range out of bounds: \(noteRange)") + continue + } + + // ✅ 检查是否与用户划线笔记重叠,如果有重叠,分割用户想法笔记的范围 + var hasOverlap = false + for (highlightRange, _) in userHighlightRangeToNotes { + let intersection = NSIntersectionRange(noteRange, highlightRange) + if intersection.length > 0 { + hasOverlap = true + + // 计算非重叠部分 + if noteRange.location < highlightRange.location { + // 用户想法笔记在用户划线笔记之前,保留前面的部分 + let beforeRange = NSRange( + location: noteRange.location, + length: highlightRange.location - noteRange.location + ) + if beforeRange.length > 0 { + var found = false + for (existingRange, existingNotes) in userThoughtRangeToNotes { + if NSEqualRanges(beforeRange, existingRange) { + userThoughtRangeToNotes[existingRange] = existingNotes + [note] + found = true + break + } + } + if !found { + userThoughtRangeToNotes[beforeRange] = [note] + } + } + } + + if NSMaxRange(noteRange) > NSMaxRange(highlightRange) { + // 用户想法笔记在用户划线笔记之后,保留后面的部分 + let afterRange = NSRange( + location: NSMaxRange(highlightRange), + length: NSMaxRange(noteRange) - NSMaxRange(highlightRange) + ) + if afterRange.length > 0 { + var found = false + for (existingRange, existingNotes) in userThoughtRangeToNotes { + if NSEqualRanges(afterRange, existingRange) { + userThoughtRangeToNotes[existingRange] = existingNotes + [note] + found = true + break + } + } + if !found { + userThoughtRangeToNotes[afterRange] = [note] + } + } + } + break + } + } + + // 如果没有重叠,直接添加整个范围 + if !hasOverlap { + var found = false + for (existingRange, existingNotes) in userThoughtRangeToNotes { + if NSEqualRanges(noteRange, existingRange) { + userThoughtRangeToNotes[existingRange] = existingNotes + [note] + found = true + break + } + } + if !found { + userThoughtRangeToNotes[noteRange] = [note] + } + } + } + + // 合并用户笔记范围(划线笔记在前) + var userRangeToNotes: [NSRange: [Note]] = userHighlightRangeToNotes + for (range, notes) in userThoughtRangeToNotes { + if let existingNotes = userRangeToNotes[range] { + userRangeToNotes[range] = existingNotes + notes + } else { + userRangeToNotes[range] = notes + } + } + + // 处理系统笔记,排除与用户笔记重叠的部分(只排除重叠部分,保留非重叠部分) + var systemRangeToNotes: [NSRange: [Note]] = [:] + for note in systemNotes { + guard let startIndex = note.startIndex, let length = note.length else { + continue + } + let noteRange = NSRange(location: startIndex, length: length) + if NSMaxRange(noteRange) > textStorage.length { + print("⚠️ [ArticleRichTextView] System note range out of bounds: \(noteRange)") + continue + } + + // ✅ 检查是否与用户笔记重叠,如果有重叠,分割系统笔记的范围 + var hasOverlap = false + for (userRange, _) in userRangeToNotes { + let intersection = NSIntersectionRange(noteRange, userRange) + if intersection.length > 0 { + hasOverlap = true + + // 计算非重叠部分 + if noteRange.location < userRange.location { + // 系统笔记在用户笔记之前,保留前面的部分 + let beforeRange = NSRange( + location: noteRange.location, + length: userRange.location - noteRange.location + ) + if beforeRange.length > 0 { + var found = false + for (existingRange, existingNotes) in systemRangeToNotes { + if NSEqualRanges(beforeRange, existingRange) { + systemRangeToNotes[existingRange] = existingNotes + [note] + found = true + break + } + } + if !found { + systemRangeToNotes[beforeRange] = [note] + } + } + } + + if NSMaxRange(noteRange) > NSMaxRange(userRange) { + // 系统笔记在用户笔记之后,保留后面的部分 + let afterRange = NSRange( + location: NSMaxRange(userRange), + length: NSMaxRange(noteRange) - NSMaxRange(userRange) + ) + if afterRange.length > 0 { + var found = false + for (existingRange, existingNotes) in systemRangeToNotes { + if NSEqualRanges(afterRange, existingRange) { + systemRangeToNotes[existingRange] = existingNotes + [note] + found = true + break + } + } + if !found { + systemRangeToNotes[afterRange] = [note] + } + } + } + break + } + } + + // 如果没有重叠,直接添加整个范围 + if !hasOverlap { + var found = false + for (existingRange, existingNotes) in systemRangeToNotes { + if NSEqualRanges(noteRange, existingRange) { + systemRangeToNotes[existingRange] = existingNotes + [note] + found = true + break + } + } + if !found { + systemRangeToNotes[noteRange] = [note] + } + } + } + + // 合并用户笔记和系统笔记的范围(用户笔记在前) + var rangeToNotes: [NSRange: [Note]] = userRangeToNotes + for (range, notes) in systemRangeToNotes { + if let existingNotes = rangeToNotes[range] { + rangeToNotes[range] = existingNotes + notes + } else { + rangeToNotes[range] = notes + } + } + + // ⚡️ 关键步骤 3:应用样式(按优先级规则,仅适用于重合部分) + // ✅ 优先级规则(仅适用于重合部分): + // 1. 有用户划线笔记时,重合部分优先显示蓝色背景,不显示想法笔记线段 + // 2. 无用户划线笔记时,重合部分用户想法笔记优先于系统想法笔记 + // 3. 系统想法笔记使用下划线样式(和用户想法笔记一样),但颜色不同(深灰色) + // ✅ 使用 beginEditing/endEditing 确保样式更新生效 + textStorage.beginEditing() + defer { textStorage.endEditing() } + + // ✅ 系统笔记颜色:深灰色下划线(微信读书风格) + let systemNoteUnderlineColor = UIColor(hex: "666666") + + for (range, rangeNotes) in rangeToNotes { + // 分离笔记类型 + let userHighlightNotes = rangeNotes.filter { !$0.isSystemNote && $0.type == .highlight } + let userThoughtNotes = rangeNotes.filter { !$0.isSystemNote && $0.type == .thought } + let systemThoughtNotes = rangeNotes.filter { $0.isSystemNote && $0.type == .thought } + let systemHighlightNotes = rangeNotes.filter { $0.isSystemNote && $0.type == .highlight } + + var attributes: [NSAttributedString.Key: Any] + + // ✅ 优先级1:有用户划线笔记时,重合部分优先显示蓝色背景,不显示想法笔记线段 + if !userHighlightNotes.isEmpty { + // 用户划线笔记:蓝色背景,无下划线 + attributes = HighlightStyle.highlightAttributes + } + // ✅ 优先级2:无用户划线笔记时,重合部分用户想法笔记优先于系统想法笔记 + else if !userThoughtNotes.isEmpty { + // 用户想法笔记:粉色虚线,无背景 + attributes = HighlightStyle.thoughtAttributes + } + // ✅ 优先级3:系统想法笔记使用下划线样式(和用户想法笔记一样),但颜色不同 + else if !systemThoughtNotes.isEmpty { + // 系统想法笔记:深灰色虚线,无背景(样式和用户想法笔记一样,但颜色不同) + let underlineStyle = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue + attributes = [ + .underlineStyle: underlineStyle, + .underlineColor: systemNoteUnderlineColor + ] + } + // ✅ 优先级4:系统划线笔记 + else if !systemHighlightNotes.isEmpty { + // 系统划线笔记:深灰色下划线 + let underlineStyle = NSUnderlineStyle.single.rawValue + attributes = [ + .underlineStyle: underlineStyle, + .underlineColor: systemNoteUnderlineColor + ] + } + // 兜底:不应该到达这里 + else { + continue + } + + // ✅ 确保保留原有的字体和颜色属性,不覆盖原有样式 + // 获取原有字体(如果存在),确保不改变字体粗细 + if let existingFont = textStorage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont { + attributes[.font] = existingFont + } + // 获取原有文字颜色(如果存在),确保不改变文字颜色 + if let existingColor = textStorage.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? UIColor { + attributes[.foregroundColor] = existingColor + } + + textStorage.addAttributes(attributes, range: range) + } + + // ⚡️ 关键:不需要 endEditing,直接操作即可 + // textStorage 的变更会自动通知 LayoutManager,但不会触发完整的布局重新计算 + // 滚动位置会保持稳定 + } + + // MARK: - Tap Detection + + // MARK: - Tap Handling + + /// 处理点击手势 + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + guard gesture.state == .ended else { return } + + // 1. 极简逻辑:只要有选中态,发生点击就立即清除并返回 + // 无论点的是选区内、选区外、还是笔记,先让蓝框消失,界面回归清爽 + // 用户需要再次点击才能打开笔记,这样交互更清晰 + if selectedRange.length > 0 { + selectedRange = NSRange(location: 0, length: 0) + return // ✅ 修正:清除选中后直接返回,不继续检测笔记 + } + + // 2. 坐标计算(只有在没有选中状态时才执行) + let point = gesture.location(in: self) + let adjustedPoint = CGPoint( + x: point.x - textContainerInset.left, + y: point.y - textContainerInset.top + ) + + // ⚡️ 直接使用 layoutManager(警告不影响功能,且更可靠) + let glyphIndex = layoutManager.glyphIndex(for: adjustedPoint, in: textContainer) + let glyphRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: textContainer) + + // 只有点击在文字范围内才触发笔记检测 + guard glyphRect.contains(adjustedPoint) else { return } + + let characterIndex = layoutManager.characterIndex(for: adjustedPoint, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) + + // 确保索引有效 + guard characterIndex >= 0, characterIndex < attributedText.length else { return } + + // 3. 笔记检测(只有在没有选中状态时才执行) + // 优先查找 thought 类型的笔记(因为它的交互更复杂) + // ✅ 修复:拆分复杂表达式,避免编译器类型检查超时 + let targetNote: Note? = { + // 先查找 thought 类型的笔记 + for note in notes where note.type == .thought { + guard let startIndex = note.startIndex, let length = note.length else { continue } + if characterIndex >= startIndex && characterIndex < (startIndex + length) { + return note + } + } + // 再查找其他类型的笔记 + for note in notes { + guard let startIndex = note.startIndex, let length = note.length else { continue } + if characterIndex >= startIndex && characterIndex < (startIndex + length) { + return note + } + } + return nil + }() + + if let note = targetNote { + // 震动反馈 + UIImpactFeedbackGenerator(style: .light).impactOccurred() + // 解决手势冲突:延迟回调,确保本次点击不会被误认为 text selection + DispatchQueue.main.async { + self.onNoteTap?(note) + } + } + } +} + +// MARK: - UITextViewDelegate (Menu Handling & Scroll Detection) +extension ArticleRichTextView: UITextViewDelegate { + + // ✅ 滚动监听:简单判断是否滚动到底部(带防抖) + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.contentSize.height > 0 else { return } + + // ✅ 防抖:限制更新频率 + let now = Date().timeIntervalSince1970 + guard now - lastProgressUpdateTime >= progressUpdateInterval else { return } + lastProgressUpdateTime = now + + let scrollOffset = scrollView.contentOffset.y + let scrollViewHeight = scrollView.bounds.height + let contentHeight = scrollView.contentSize.height + let bottomInset = scrollView.contentInset.bottom + + // ✅ 简单判断:可见区域底部是否接近内容底部 + let visibleBottom = scrollOffset + scrollViewHeight - bottomInset + let distanceFromBottom = contentHeight - visibleBottom + + let progress: Float + // 如果距离底部 < 100pt,认为已经滚动到底部(用户能看到最后一行) + if distanceFromBottom < 100 { + progress = 1.0 + } else { + // 计算当前进度 + progress = min(1.0, max(0.0, Float(visibleBottom / contentHeight))) + } + + // ✅ 只有当进度变化超过 0.05 时才回调(避免频繁更新) + if abs(progress - lastReportedProgress) > 0.05 || progress == 1.0 { + lastReportedProgress = progress + onScrollProgress?(progress) + } + } + + // MARK: - Scroll Control + /// 滚动到指定字符位置 + func scrollToCharacter(at index: Int) { + guard index >= 0, index < textStorage.length else { + print("⚠️ [ArticleRichTextView] scrollToCharacter: 索引越界 index=\(index), length=\(textStorage.length)") + return + } + guard contentSize.height > bounds.height else { + print("⚠️ [ArticleRichTextView] scrollToCharacter: 单屏内容,无需滚动") + return + } + + // 将 NSRange 转换为 UITextRange + guard let startPosition = position(from: beginningOfDocument, offset: index), + let endPosition = position(from: startPosition, offset: 1), + let textRange = textRange(from: startPosition, to: endPosition) else { + print("⚠️ [ArticleRichTextView] scrollToCharacter: 无法创建 UITextRange") + return + } + + // 获取目标位置的矩形 + let rect = firstRect(for: textRange) + + // 计算滚动偏移:让目标位置在屏幕中间偏上(30%) + let targetOffset = max(0, rect.origin.y - bounds.height * 0.3) + + print("✅ [ArticleRichTextView] scrollToCharacter: index=\(index), rect=\(rect), targetOffset=\(targetOffset)") + + setContentOffset(CGPoint(x: 0, y: targetOffset), animated: true) + } + + // MARK: - Menu Delegate + /// iOS 16+ 自定义菜单:只显示"划线"、"想法"、"复制"三个按钮 + @available(iOS 16.0, *) + func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + + // 1. 划线按钮 + let highlightAction = UIAction(title: "划线", image: UIImage(systemName: "highlighter")) { [weak self] _ in + self?.handleMenuAction(type: .highlight, range: range) + } + + // 2. 想法按钮 + let thoughtAction = UIAction(title: "写想法", image: UIImage(systemName: "bubble.left.and.bubble.right")) { [weak self] _ in + self?.handleMenuAction(type: .thought, range: range) + } + + // 3. 复制按钮 (手动实现,以便复制后清除选中态) + let copyAction = UIAction(title: "复制", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in + guard let self = self else { return } + + // 获取选中文本并复制 + if let start = self.position(from: self.beginningOfDocument, offset: range.location), + let end = self.position(from: start, offset: range.length), + let textRange = self.textRange(from: start, to: end), + let text = self.text(in: textRange) { + + UIPasteboard.general.string = text + + // 体验优化:复制完立即清除选中态 + self.selectedRange = NSRange(location: 0, length: 0) + + // 复制成功后回调,用于展示「已复制」Toast + DispatchQueue.main.async { + self.onCopySuccess?() + } + } + } + + // 4. 重组菜单:只放这三个按钮,忽略系统推荐的 suggestedActions + return UIMenu(children: [highlightAction, thoughtAction, copyAction]) + } + + // 处理菜单点击 + private func handleMenuAction(type: NoteType, range: NSRange) { + // ✅ 修正:范围校验 + guard range.location >= 0, + range.length > 0, + range.location + range.length <= attributedText.length else { + print("⚠️ [ArticleRichTextView] 无效的选择范围: \(range), 文档长度: \(attributedText.length)") + return + } + + // 立即清除选区,提供更好的视觉反馈 + self.selectedRange = NSRange(location: 0, length: 0) + + if type == .highlight { + onHighlightAction?(range) + } else { + onThoughtAction?(range) + } + } +} diff --git a/ios/WildGrowth/WildGrowth/ArticleRichTextViewRepresentable.swift b/ios/WildGrowth/WildGrowth/ArticleRichTextViewRepresentable.swift new file mode 100644 index 0000000..0b1d092 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/ArticleRichTextViewRepresentable.swift @@ -0,0 +1,228 @@ +import SwiftUI +import UIKit + +// SwiftUI 桥接组件 +struct ArticleRichTextViewRepresentable: UIViewRepresentable { + + // MARK: - Constants + private static let topInset: CGFloat = 28 // ✅ 顶部内边距,增加呼吸感 + + // MARK: - Data Source + let lessonDetail: LessonDetail? + let content: NSAttributedString? + let notes: [Note] + + // Header 配置 (可选,保持向后兼容) + let headerConfig: HeaderConfig? + + /// 高亮色(与进度条主题色一致),nil 时用 brandVital + let accentColor: Color? + + let initialScrollIndex: Int? + + // MARK: - Callbacks + var onHighlight: ((NSRange) -> Void)? + var onThought: ((NSRange) -> Void)? + var onNoteTap: ((Note) -> Void)? + var onScrollProgress: ((Float) -> Void)? + var onCopySuccess: (() -> Void)? + + // ✅ 兼容模式 A:纯文本 + init( + content: NSAttributedString, + notes: [Note] = [], + initialScrollIndex: Int? = nil, + headerConfig: HeaderConfig? = nil, + accentColor: Color? = nil, + onHighlight: ((NSRange) -> Void)? = nil, + onThought: ((NSRange) -> Void)? = nil, + onNoteTap: ((Note) -> Void)? = nil, + onScrollProgress: ((Float) -> Void)? = nil, + onCopySuccess: (() -> Void)? = nil + ) { + self.content = content + self.lessonDetail = nil + self.notes = notes + self.initialScrollIndex = initialScrollIndex + self.headerConfig = headerConfig + self.accentColor = accentColor + self.onHighlight = onHighlight + self.onThought = onThought + self.onNoteTap = onNoteTap + self.onScrollProgress = onScrollProgress + self.onCopySuccess = onCopySuccess + } + + // ✅ 兼容模式 B:详情对象 + init( + lessonDetail: LessonDetail, + notes: [Note] = [], + initialScrollIndex: Int? = nil, + headerConfig: HeaderConfig? = nil, + accentColor: Color? = nil, + onHighlight: ((NSRange) -> Void)? = nil, + onThought: ((NSRange) -> Void)? = nil, + onNoteTap: ((Note) -> Void)? = nil, + onScrollProgress: ((Float) -> Void)? = nil, + onCopySuccess: (() -> Void)? = nil + ) { + self.lessonDetail = lessonDetail + self.content = nil + self.notes = notes + self.initialScrollIndex = initialScrollIndex + self.headerConfig = headerConfig + self.accentColor = accentColor + self.onHighlight = onHighlight + self.onThought = onThought + self.onNoteTap = onNoteTap + self.onScrollProgress = onScrollProgress + self.onCopySuccess = onCopySuccess + } + + // Coordinator 处理滚动状态 + func makeCoordinator() -> Coordinator { + Coordinator(initialScrollIndex: initialScrollIndex) + } + + func makeUIView(context: Context) -> ArticleRichTextView { + let textView = ArticleRichTextView() + textView.onHighlightAction = onHighlight + textView.onThoughtAction = onThought + textView.onNoteTap = onNoteTap + textView.onScrollProgress = onScrollProgress + textView.onCopySuccess = onCopySuccess + + // ✅ 修改点:增加顶部内边距 (Top Inset),让标题下沉,不再顶着进度条 + textView.textContainerInset = UIEdgeInsets( + top: Self.topInset, + left: 24, + bottom: 140, + right: 24 + ) + + updateContent(for: textView) + + // ✅ 恢复:健壮的初始滚动逻辑 + if let scrollIndex = context.coordinator.initialScrollIndex, !context.coordinator.hasScrolled { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if textView.textStorage.length > 0 { + textView.scrollToCharacter(at: scrollIndex) + context.coordinator.hasScrolled = true + } else { + // 二次尝试 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if textView.textStorage.length > 0 { + textView.scrollToCharacter(at: scrollIndex) + context.coordinator.hasScrolled = true + } + } + } + } + } + + return textView + } + + func updateUIView(_ uiView: ArticleRichTextView, context: Context) { + uiView.onHighlightAction = onHighlight + uiView.onThoughtAction = onThought + uiView.onNoteTap = onNoteTap + uiView.onScrollProgress = onScrollProgress + uiView.onCopySuccess = onCopySuccess + + // ✅ 修改点:确保更新时保持间距一致,防止被系统重置 + if uiView.textContainerInset.top != Self.topInset { + uiView.textContainerInset = UIEdgeInsets( + top: Self.topInset, + left: 24, + bottom: 140, + right: 24 + ) + } + + let contentChanged = updateContent(for: uiView) + + // ✅ 恢复滚动逻辑 + if let scrollIndex = context.coordinator.initialScrollIndex, + !context.coordinator.hasScrolled, + contentChanged { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if uiView.textStorage.length > 0 { + uiView.scrollToCharacter(at: scrollIndex) + context.coordinator.hasScrolled = true + } else { + // 二次尝试 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if uiView.textStorage.length > 0 { + uiView.scrollToCharacter(at: scrollIndex) + context.coordinator.hasScrolled = true + } + } + } + } + } + } + + @discardableResult + private func updateContent(for view: ArticleRichTextView) -> Bool { + if let detail = lessonDetail { + let fullContent = buildContentWithHeader(detail: detail) + view.update(content: fullContent, notes: notes) + return true + } else if let content = content { + view.update(content: content, notes: notes) + return true + } else { + view.update(content: NSAttributedString(), notes: notes) + return false + } + } + + private func buildContentWithHeader(detail: LessonDetail) -> NSAttributedString { + let uiAccent = accentColor.map { UIColor($0) } + guard let config = headerConfig else { + return ContentBlockBuilder.buildAttributedString(from: detail.blocks, headerInfo: detail, accentColor: uiAccent) + } + + let finalStr = NSMutableAttributedString() + + // 1. 章节标题 + if config.showChapterTitle, let cTitle = config.chapterTitle { + let chapterAttr: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 15, weight: .medium), + .foregroundColor: UIColor.inkSecondary, + .paragraphStyle: { + let p = NSMutableParagraphStyle() + p.paragraphSpacing = 8 + return p + }() + ] + finalStr.append(NSAttributedString(string: "\(cTitle)\n", attributes: chapterAttr)) + } + + // 2. 课程标题 + let titleAttr: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 28, weight: .bold), + .foregroundColor: UIColor.inkPrimary, + .paragraphStyle: { + let p = NSMutableParagraphStyle() + p.paragraphSpacing = 16 + p.lineSpacing = 4 + return p + }() + ] + finalStr.append(NSAttributedString(string: "\(detail.title)\n", attributes: titleAttr)) + + // 3. 正文 (调用无 Header 重载,传入主题色) + let bodyContent = ContentBlockBuilder.buildAttributedString(from: detail.blocks, accentColor: uiAccent) + finalStr.append(bodyContent) + + return finalStr + } + + class Coordinator { + let initialScrollIndex: Int? + var hasScrolled: Bool = false + init(initialScrollIndex: Int?) { self.initialScrollIndex = initialScrollIndex } + } +} diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/WildGrowth/WildGrowth/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/100.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..5fcacbd Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/102.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/102.png new file mode 100644 index 0000000..f1e0ad8 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/102.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/1024.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..9871b5f Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/108.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/108.png new file mode 100644 index 0000000..2e7200f Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/108.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/114.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..6616644 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/120.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..8402bc1 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/128.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/128.png new file mode 100644 index 0000000..2baf22b Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/144.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..94b4f62 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/152.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..7acdd0f Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/16.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/16.png new file mode 100644 index 0000000..12aa2e7 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/167.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..7e73c8e Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/172.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 0000000..9c97c08 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/172.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/180.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..5458072 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/196.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 0000000..a0355e4 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/196.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/20.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..905ea33 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/216.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/216.png new file mode 100644 index 0000000..af8e520 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/216.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/234.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/234.png new file mode 100644 index 0000000..3b42a23 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/234.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/256.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 0000000..87c1082 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/258.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/258.png new file mode 100644 index 0000000..9a6f441 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/258.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/29.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..71bee4a Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/32.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/32.png new file mode 100644 index 0000000..c320022 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/40.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..5d04c7b Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/48.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 0000000..a306fe0 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/48.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/50.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..ff39be2 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/512.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 0000000..55880a3 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/55.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 0000000..31067be Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/55.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/57.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..81a9af9 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/58.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..254e426 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/60.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..ec14468 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/64.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 0000000..5741213 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/64.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/66.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/66.png new file mode 100644 index 0000000..3b9649a Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/66.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/72.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..67430b5 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/76.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..c2d57aa Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/80.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..2129c66 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/87.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..f9c5f4f Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/88.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/88.png new file mode 100644 index 0000000..c4cf024 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/88.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/92.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/92.png new file mode 100644 index 0000000..df087ca Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/92.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..1319290 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} \ No newline at end of file diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形 1.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形 1.png new file mode 100644 index 0000000..9888528 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形 1.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形 2.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形 2.png new file mode 100644 index 0000000..9888528 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形 2.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形.png b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形.png new file mode 100644 index 0000000..9888528 Binary files /dev/null and b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/1227MVP终稿圆角矩形.png differ diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/Contents.json b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..f5a7d30 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Assets.xcassets/AppLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "1227MVP终稿圆角矩形 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "1227MVP终稿圆角矩形 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "1227MVP终稿圆角矩形.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WildGrowth/WildGrowth/Assets.xcassets/Contents.json b/ios/WildGrowth/WildGrowth/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WildGrowth/WildGrowth/AuthModels.swift b/ios/WildGrowth/WildGrowth/AuthModels.swift new file mode 100644 index 0000000..58fb0e5 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/AuthModels.swift @@ -0,0 +1,37 @@ +import Foundation + +// 通用响应包装 +struct APIResponse: Codable { + let success: Bool + let data: T +} + +// 登录成功返回的数据 +struct LoginData: Codable { + let token: String + let user: UserInfo +} + +// 用户信息 +struct UserInfo: Codable { + let id: String + let nickname: String + let phone: String? + let avatar: String? + let digitalId: String? // ✅ 赛博学习证ID (Wild ID) + + enum CodingKeys: String, CodingKey { + case id, nickname, phone, avatar + case digitalId = "digitalId" // ✅ 明确映射,支持 camelCase + } +} + +// 发送验证码响应 (后端只返回 message) +struct SimpleResponse: Codable { + let success: Bool + let message: String? +} + + + + diff --git a/ios/WildGrowth/WildGrowth/AuthService.swift b/ios/WildGrowth/WildGrowth/AuthService.swift new file mode 100644 index 0000000..eaf7367 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/AuthService.swift @@ -0,0 +1,42 @@ +import Foundation + +class AuthService { + static let shared = AuthService() + + // 发送验证码 + func sendCode(phone: String) async throws -> Bool { + let response: SimpleResponse = try await APIClient.shared.request( + endpoint: "/api/auth/send-code", + method: "POST", + body: ["phone": phone] + ) + return response.success + } + + // 手机号登录 + func login(phone: String, code: String) async throws -> LoginData { + let response: APIResponse = try await APIClient.shared.request( + endpoint: "/api/auth/login", + method: "POST", + body: ["phone": phone, "code": code] + ) + return response.data + } + + // Apple 登录 (模拟) + func appleLogin(identityToken: String, authCode: String) async throws -> LoginData { + let response: APIResponse = try await APIClient.shared.request( + endpoint: "/api/auth/apple-login", + method: "POST", + body: [ + "identity_token": identityToken, + "authorization_code": authCode + ] + ) + return response.data + } +} + + + + diff --git a/ios/WildGrowth/WildGrowth/ContentBlockBuilder.swift b/ios/WildGrowth/WildGrowth/ContentBlockBuilder.swift new file mode 100644 index 0000000..6526a0f --- /dev/null +++ b/ios/WildGrowth/WildGrowth/ContentBlockBuilder.swift @@ -0,0 +1,374 @@ +import UIKit +import Foundation + +/// 内容块构建器 +/// 职责:将 [ContentBlock] 数组转换为单个 NSAttributedString +struct ContentBlockBuilder { + + // MARK: - 样式常量(与 VerticalScreenPlayerView 保持一致) + + private static let bodyFontSize: CGFloat = 19 // ✅ 优化:17 → 19,提升阅读舒适度 + private static let bodyLineSpacing: CGFloat = 10 // ✅ 优化:调整为黄金比例 1.72倍行高 (19*1.72≈32.7, lineSpacing≈10) + private static let paragraphSpacing: CGFloat = 24 // ✅ 优化:22 → 24,段落间距随正文字号增大 + + // MARK: - 核心方法 + + /// 将 ContentBlock 数组转换为单个 NSAttributedString(向后兼容) + /// - Parameters: + /// - blocks: ContentBlock 数组 + /// - accentColor: 高亮色(与进度条主题色一致),nil 时用 brandVital + /// - Returns: 合并后的 NSAttributedString + static func buildAttributedString(from blocks: [ContentBlock], accentColor: UIColor? = nil) -> NSAttributedString { + let result = NSMutableAttributedString() + appendBlocks(blocks, to: result, accentColor: accentColor) + return result + } + + /// 将 ContentBlock 数组转换为单个 NSAttributedString(包含头部信息) + /// + /// 【极简主义重构说明 - 给 Gemini】 + /// 本次重构移除了头部冗余信息,实现极简阅读体验: + /// 1. 移除问候语:"作者,你好" 文案已删除 + /// 2. 移除简介:headerInfo.intro 不再显示 + /// 3. 只保留标题:标题样式调整为紧凑布局(paragraphSpacing = 24, paragraphSpacingBefore = 10) + /// 4. 标题去重:如果正文第一个 block 是标题且内容相同,自动跳过避免重复 + /// + /// 注意:标题保留在 NSAttributedString 中,确保笔记的 NSRange 索引依然有效 + /// + /// - Parameters: + /// - blocks: ContentBlock 数组 + /// - headerInfo: 课程详情(用于构建标题) + /// - accentColor: 高亮色(与进度条主题色一致),nil 时用 brandVital + /// - Returns: 合并后的 NSAttributedString(只包含标题和正文) + static func buildAttributedString( + from blocks: [ContentBlock], + headerInfo: LessonDetail, + accentColor: UIColor? = nil + ) -> NSAttributedString { + let result = NSMutableAttributedString() + + // ========================================== + // 1. 头部构建(极简版:只保留标题) + // ========================================== + + // 主标题 (30pt Heavy, 醒目) ✅ 优化:28 → 30,随正文字号调整 + let titleAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 30, weight: .heavy), // ✅ 优化:28 → 30 + .foregroundColor: UIColor.inkPrimary, + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.lineHeightMultiple = 1.1 + style.paragraphSpacing = 36 // ✅ 优化:32 → 36,标题与正文间距加大 + style.paragraphSpacingBefore = 10 + style.alignment = .left + return style + }(), + .kern: 0.5 + ] + result.append(NSAttributedString(string: headerInfo.title + "\n", attributes: titleAttrs)) + + // ❌ 已移除:问候语和简介("作者,你好" 和 intro 文案) + + // ========================================== + // 2. 正文构建 + // ========================================== + + // ✅ 标题去重逻辑:防止正文第一个 block 重复显示标题 + var blocksToAppend = blocks + if !blocks.isEmpty, + let firstBlock = blocks.first, + firstBlock.type == .heading1 { + let firstBlockContent = firstBlock.content.trimmingCharacters(in: .whitespacesAndNewlines) + let headerTitle = headerInfo.title.trimmingCharacters(in: .whitespacesAndNewlines) + + // 精确匹配:如果第一个 block 是标题且内容相同,则跳过 + if firstBlockContent == headerTitle { + blocksToAppend = Array(blocks.dropFirst()) + } + } + + appendBlocks(blocksToAppend, to: result, accentColor: accentColor) + + return result + } + + // MARK: - Private Helpers + + /// 复用块拼接逻辑:用「段后间距」保证段间松(paragraphSpacingAfter 比段前+换行更稳定) + private static func appendBlocks(_ blocks: [ContentBlock], to result: NSMutableAttributedString, accentColor: UIColor? = nil) { + for (index, block) in blocks.enumerated() { + let isLastBlock = (index == blocks.count - 1) + let spacingAfter: CGFloat? = isLastBlock ? nil : paragraphSpacing + let blockContent = buildBlockContent(block, accentColor: accentColor, paragraphSpacingAfter: spacingAfter) + result.append(blockContent) + if !isLastBlock { + result.append(NSAttributedString(string: "\n", attributes: [:])) + } + } + } + + // MARK: - Block 内容构建 + + /// 根据 Block 类型构建 NSAttributedString;paragraphSpacingAfter 用于段间松(仅对 paragraph 等生效) + /// 正文内:### / #### / ## 直接删掉不做样式;行首 - 或 - 后直接跟字 统一成 • + private static func buildBlockContent(_ block: ContentBlock, accentColor: UIColor? = nil, paragraphSpacingAfter: CGFloat? = nil) -> NSAttributedString { + switch block.type { + case .heading1: + return buildHeading1(block.content) + case .heading2: + return buildHeading2(block.content) + case .paragraph: + let raw = block.content + let withoutHeading = stripMarkdownHeadingPrefix(raw) + let contentWithBullets = replaceLineStartHyphenWithBullet(withoutHeading) + return buildParagraph(contentWithBullets, accentColor: accentColor, spacingAfter: paragraphSpacingAfter) + case .quote: + return buildQuote(block.content) + case .highlight: + return buildHighlight(block.content, accentColor: accentColor) + case .list: + return buildList(block.content) + } + } + + // MARK: - 各类型 Block 的构建方法 + + /// Heading1: 26pt bold, 上下间距 28pt/10pt ✅ 优化:24 → 26 + private static func buildHeading1(_ content: String) -> NSAttributedString { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 28 + paragraphStyle.paragraphSpacing = 10 + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 26, weight: .bold), // ✅ 优化:24 → 26 + .foregroundColor: UIColor.inkPrimary, + .paragraphStyle: paragraphStyle + ] + + return NSAttributedString(string: content, attributes: attributes) + } + + /// Heading2: 22pt bold, 上下间距 20pt/8pt ✅ 优化:20 → 22 + private static func buildHeading2(_ content: String) -> NSAttributedString { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 20 + paragraphStyle.paragraphSpacing = 8 + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 22, weight: .bold), // ✅ 优化:20 → 22 + .foregroundColor: UIColor.inkPrimary, + .paragraphStyle: paragraphStyle + ] + + return NSAttributedString(string: content, attributes: attributes) + } + + /// Paragraph: 17pt, 行高 29.3pt (1.72倍), 字间距 0.5pt;spacingAfter 为段后间距(段间松) + private static func buildParagraph(_ content: String, accentColor: UIColor? = nil, spacingAfter: CGFloat? = nil) -> NSAttributedString { + return parseHTMLContent(content, baseAttributes: getParagraphAttributes(spacingAfter: spacingAfter), accentColor: accentColor) + } + + /// 去掉段首的 #### / ### / ## / # 前缀,不改变样式(直接删掉) + private static func stripMarkdownHeadingPrefix(_ content: String) -> String { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("#### ") { + return String(trimmed.dropFirst(5)) + } + if trimmed.hasPrefix("### ") { + return String(trimmed.dropFirst(4)) + } + if trimmed.hasPrefix("## ") { + return String(trimmed.dropFirst(3)) + } + if trimmed.hasPrefix("# ") { + return String(trimmed.dropFirst(2)) + } + return content + } + + /// 将正文中「行首 - 」或「行首 - 后直接跟字」统一成「• 」,用于支持 AI 输出的 Markdown 列表 + private static func replaceLineStartHyphenWithBullet(_ content: String) -> String { + let lines = content.components(separatedBy: "\n") + let processed = lines.map { line -> String in + let dropCount = line.prefix(while: { $0.isWhitespace }).count + let rest = line.dropFirst(dropCount) + if rest.hasPrefix("- ") { + let indent = String(line.prefix(dropCount)) + return indent + "• " + rest.dropFirst(2) + } + if rest.hasPrefix("-") { + let indent = String(line.prefix(dropCount)) + if rest.count == 1 { + return indent + "• " + } + if !(rest.dropFirst(1).first?.isWhitespace ?? true) { + return indent + "• " + rest.dropFirst(1) + } + } + return line + } + return processed.joined(separator: "\n") + } + + /// Quote: 17pt medium, 左侧竖线, 灰色文字, 行距 8pt + private static func buildQuote(_ content: String) -> NSAttributedString { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = 8 + paragraphStyle.firstLineHeadIndent = 12 // 左侧留出竖线空间 + paragraphStyle.headIndent = 12 + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: bodyFontSize, weight: .medium), + .foregroundColor: UIColor.inkSecondary, + .paragraphStyle: paragraphStyle + ] + + // TODO: 左侧竖线可以通过 NSTextAttachment 或自定义绘制实现 + // 这里先用文本缩进模拟 + let quoteContent = "│ " + content // 临时方案:使用字符模拟竖线 + return NSAttributedString(string: quoteContent, attributes: attributes) + } + + /// Highlight: 20pt bold, 背景色, 圆角, 内边距 ✅ 优化:18 → 20 + private static func buildHighlight(_ content: String, accentColor: UIColor? = nil) -> NSAttributedString { + let color = accentColor ?? UIColor.brandVital + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = bodyLineSpacing + 2 + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 20, weight: .bold), // ✅ 优化:18 → 20 + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + .backgroundColor: color.withAlphaComponent(0.08) + ] + + return NSAttributedString(string: content, attributes: attributes) + } + + /// List: 项目符号 "• ", 17pt + private static func buildList(_ content: String) -> NSAttributedString { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.firstLineHeadIndent = 0 + paragraphStyle.headIndent = 20 // 项目符号后的缩进 + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: bodyFontSize), + .foregroundColor: UIColor.inkBody, + .paragraphStyle: paragraphStyle, + .kern: 0.5 + ] + + let listContent = "• " + content + return NSAttributedString(string: listContent, attributes: attributes) + } + + // MARK: - 样式辅助方法 + + /// 获取段落基础样式;spacingAfter 为段后间距(段间松),nil 表示不额外加 + private static func getParagraphAttributes(spacingAfter: CGFloat? = nil) -> [NSAttributedString.Key: Any] { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + if let after = spacingAfter { + paragraphStyle.paragraphSpacing = after + } + + let font = UIFont.systemFont(ofSize: bodyFontSize) + let lineSpacing: CGFloat = bodyLineSpacing + + let baseLineHeight = font.lineHeight + paragraphStyle.minimumLineHeight = baseLineHeight + lineSpacing + paragraphStyle.maximumLineHeight = baseLineHeight + lineSpacing + paragraphStyle.lineSpacing = 0 + + return [ + .font: font, + .foregroundColor: UIColor.inkBody, + .paragraphStyle: paragraphStyle, + .kern: 0.5 + ] + } + + /// 解析 HTML 内容(处理
标签) + private static func parseHTMLContent(_ rawString: String, baseAttributes: [NSAttributedString.Key: Any], accentColor: UIColor? = nil) -> NSAttributedString { + let cleanString = rawString.replacingOccurrences(of: "
", with: "\n") + let result = NSMutableAttributedString() + + let highlightColor = accentColor ?? UIColor.brandVital + var highlightAttributes = baseAttributes + highlightAttributes[.foregroundColor] = highlightColor + highlightAttributes[.font] = UIFont.systemFont(ofSize: bodyFontSize, weight: .bold) + + // 分割 + let parts = cleanString.components(separatedBy: "") + + for (index, part) in parts.enumerated() { + if index == 0 { + // 开头普通文本(含 需解析) + result.append(parseBoldTags(part, baseAttributes: baseAttributes)) + } else { + let subParts = part.components(separatedBy: "") + if !subParts.isEmpty { + // 高亮部分(可能内含 ,解析后统一为高亮样式即可,主要去掉 字面量) + result.append(parseBoldTags(subParts[0], baseAttributes: highlightAttributes)) + + // 剩余普通部分(含 需解析) + if subParts.count > 1 { + result.append(parseBoldTags(subParts[1], baseAttributes: baseAttributes)) + } + } else { + result.append(parseBoldTags(part, baseAttributes: baseAttributes)) + } + } + } + + return result + } + + /// 解析 ...,将加粗区间应用粗体,其余用 baseAttributes;未闭合的 视作到文末加粗,多余 舍弃。 + private static func parseBoldTags(_ s: String, baseAttributes: [NSAttributedString.Key: Any]) -> NSAttributedString { + let res = NSMutableAttributedString() + let baseFont = baseAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: bodyFontSize) + var boldAttrs = baseAttributes + boldAttrs[.font] = UIFont.systemFont(ofSize: baseFont.pointSize, weight: .bold) + + let openTag = "" + let closeTag = "" + var i = s.startIndex + + while i < s.endIndex { + if let openRange = s.range(of: openTag, range: i.. 前的普通文本 + res.append(NSAttributedString(string: String(s[i..,到文末为粗体 + res.append(NSAttributedString(string: String(s[afterOpen...]), attributes: boldAttrs)) + i = s.endIndex + } + } else { + // 无 :若有孤儿 则跳过,其余为普通 + if let closeRange = s.range(of: closeTag, range: i.. NSParagraphStyle { + let style = NSMutableParagraphStyle() + style.paragraphSpacingBefore = spacingBefore + return style + } +} + +// MARK: - 注意 +// UIColor 扩展已在 DesignSystem+UIKit.swift 中定义,无需重复定义 diff --git a/ios/WildGrowth/WildGrowth/ContentParser.swift b/ios/WildGrowth/WildGrowth/ContentParser.swift new file mode 100644 index 0000000..b4191f5 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/ContentParser.swift @@ -0,0 +1,115 @@ +import SwiftUI +import UIKit + +struct ContentParser { + /// 将带标签的字符串转换为 SwiftUI Text(保留原有方法,向后兼容) + static func parse(_ rawString: String) -> Text { + // 1. 处理换行符
-> \n + let cleanString = rawString.replacingOccurrences(of: "
", with: "\n") + + // 2. 分割高亮标签 ... + // 简单的字符串切分法,性能优于正则 + let parts = cleanString.components(separatedBy: "") + + var combinedText = Text("") + + for (index, part) in parts.enumerated() { + if index == 0 { + // 第一部分通常是普通文本 + combinedText = combinedText + Text(part).foregroundColor(.inkPrimary) + } else { + // 后续部分开头包含高亮内容,需要找闭合标签 + let subParts = part.components(separatedBy: "
") + if subParts.count > 0 { + // 高亮部分 + let highlightedText = Text(subParts[0]) + .fontWeight(.bold) + .foregroundColor(.brandMoss) // 字体变绿 + // 如果需要背景色高亮,可以在这里修改 + + combinedText = combinedText + highlightedText + + // 剩余普通文本 + if subParts.count > 1 { + combinedText = combinedText + Text(subParts[1]).foregroundColor(.inkPrimary) + } + } else { + combinedText = combinedText + Text(part).foregroundColor(.inkPrimary) + } + } + } + + return combinedText + } + + // MARK: - TextKit 2 Support (新增方法) + + /// 核心样式配置(TextKit 2 专用) + /// 优化为"紧凑·舒适"的黄金比例阅读体验:17pt 字体,9pt 行间距,0.5pt 字间距 + /// 总行高约 29.3pt(1.72倍),符合移动端长文阅读的最佳舒适区 + private static let baseAttributes: [NSAttributedString.Key: Any] = { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + + // ✅ 优化:回归黄金比例行高(1.6-1.7倍) + // 17pt 系统字体的基础行高约为 20.3pt(SF Pro 标准) + // 使用 minimumLineHeight 方式确保精确控制,避免字体被压缩 + let font = UIFont.systemFont(ofSize: 17) + let lineSpacing: CGFloat = 9 // ✅ 从 15 调整为 9,达到 1.72倍行高黄金比例 + + // 计算合理的最小行高 + // 17pt 字体的基础行高 ≈ 20.3pt,加上 9pt 额外间距 = 29.3pt + // 总行高约为字号的 1.72 倍,既消除拥挤感,又避免支离破碎 + let baseLineHeight = font.lineHeight // 系统自动计算的基础行高(约 20.3pt) + paragraphStyle.minimumLineHeight = baseLineHeight + lineSpacing // 总行高 = 基础 + 额外间距 + paragraphStyle.maximumLineHeight = baseLineHeight + lineSpacing + paragraphStyle.lineSpacing = 0 // 因为已经在 minimumLineHeight 中包含了间距 + + return [ + .font: font, + .foregroundColor: UIColor.inkBody, // ✅ 严格匹配:#333333 + .paragraphStyle: paragraphStyle, + .kern: 0.5 // ✅ 严格匹配:字间距 0.5 + ] + }() + + /// HTML 预设高亮样式 (仅变色加粗) + private static let highlightAttributes: [NSAttributedString.Key: Any] = { + var attrs = baseAttributes + attrs[.foregroundColor] = UIColor.brandVital + attrs[.font] = UIFont.systemFont(ofSize: 17, weight: .bold) + return attrs + }() + + /// 解析 HTML 字符串为 TextKit 2 可用的 NSAttributedString(新增方法) + static func parseToAttributed(_ rawString: String) -> NSAttributedString { + let cleanString = rawString.replacingOccurrences(of: "
", with: "\n") + let finalString = NSMutableAttributedString() + + // 分割 + let parts = cleanString.components(separatedBy: "") + + for (index, part) in parts.enumerated() { + if index == 0 { + // 开头普通文本 + finalString.append(NSAttributedString(string: part, attributes: baseAttributes)) + } else { + let subParts = part.components(separatedBy: "") + if !subParts.isEmpty { + // 高亮部分 + finalString.append(NSAttributedString(string: subParts[0], attributes: highlightAttributes)) + + // 剩余普通部分 + if subParts.count > 1 { + finalString.append(NSAttributedString(string: subParts[1], attributes: baseAttributes)) + } + } else { + finalString.append(NSAttributedString(string: part, attributes: baseAttributes)) + } + } + } + + return finalString + } +} + diff --git a/ios/WildGrowth/WildGrowth/CourseModels.swift b/ios/WildGrowth/WildGrowth/CourseModels.swift new file mode 100644 index 0000000..ab274dd --- /dev/null +++ b/ios/WildGrowth/WildGrowth/CourseModels.swift @@ -0,0 +1,403 @@ +import Foundation + +// MARK: - API 响应外层结构 +struct CourseListResponse: Codable { + let success: Bool + let data: CourseListData +} + +struct CourseListData: Codable { + let courses: [Course] +} + +// MARK: - 课程状态枚举 +// [Fix] 添加 Hashable 支持 +enum CourseStatus: String, Codable, Hashable { + case notStarted = "not_started" + case inProgress = "in_progress" + case completed = "completed" + case generating = "generating" // ✅ 新增:创建中的课程状态 +} + +// MARK: - 课程类型枚举 +enum CourseType: String, Codable, Hashable { + case system = "system" // 体系课 + case single = "single" // 小节课 + case verticalScreen = "vertical_screen" // 竖屏课程 +} + +// MARK: - 课程模型 +// [Fix] 添加 Hashable 支持,解决 NavigationStack 路径报错问题 +struct Course: Identifiable, Codable, Hashable { + let id: String + let title: String + let description: String? + let coverImage: String + let themeColor: String? // ✅ 新增:主题色 Hex(如 "#2266FF") + let watermarkIcon: String? // ✅ 新增:水印图标名称(SF Symbol,如 "book.closed.fill") + + // ✅ 新增字段 + let type: CourseType + let firstNodeId: String? + let firstNodeOrder: Int? + let isPortrait: Bool // ✅ 新增:是否为竖屏课程 + + let status: String // ✅ 修复:改为String,支持"generating"等状态 + let progress: Int + let totalNodes: Int + let completedNodes: Int + + // 映射后端字段 (snake_case -> camelCase) + enum CodingKeys: String, CodingKey { + case id, title, description, status, progress + case coverImage = "cover_image" + case themeColor = "theme_color" + case watermarkIcon = "watermark_icon" + case totalNodes = "total_nodes" + case completedNodes = "completed_nodes" + // ✅ 映射新字段 + case type + case firstNodeId = "first_node_id" + case firstNodeOrder = "first_node_order" + case isPortrait = "is_portrait" // ✅ 新增:竖屏课程标识 + } + + // ✅ 自定义解码 (处理向后兼容) + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + description = try container.decodeIfPresent(String.self, forKey: .description) + coverImage = try container.decode(String.self, forKey: .coverImage) + themeColor = try container.decodeIfPresent(String.self, forKey: .themeColor) // ✅ 新增 + watermarkIcon = try container.decodeIfPresent(String.self, forKey: .watermarkIcon) // ✅ 新增 + status = try container.decode(String.self, forKey: .status) // ✅ 修复:改为String类型 + progress = try container.decode(Int.self, forKey: .progress) + totalNodes = try container.decode(Int.self, forKey: .totalNodes) + completedNodes = try container.decode(Int.self, forKey: .completedNodes) + + // 🔥 兼容逻辑:如果没有 type 字段,默认为 .system + type = try container.decodeIfPresent(CourseType.self, forKey: .type) ?? .system + + firstNodeId = try container.decodeIfPresent(String.self, forKey: .firstNodeId) + firstNodeOrder = try container.decodeIfPresent(Int.self, forKey: .firstNodeOrder) + + // ✅ 解析 is_portrait 字段,默认为 false + isPortrait = try container.decodeIfPresent(Bool.self, forKey: .isPortrait) ?? false + } + + // ✅ 标准初始化器 (用于 Preview/Mock) + init(id: String, + title: String, + description: String? = nil, + coverImage: String, + themeColor: String? = nil, // ✅ 新增 + watermarkIcon: String? = nil, // ✅ 新增 + type: CourseType = .system, // 默认值 + firstNodeId: String? = nil, + firstNodeOrder: Int? = nil, + isPortrait: Bool = false, // ✅ 新增:默认横屏 + status: String = "not_started", // ✅ 修复:改为String类型 + progress: Int = 0, + totalNodes: Int = 0, + completedNodes: Int = 0) { + + self.id = id + self.title = title + self.description = description + self.coverImage = coverImage + self.themeColor = themeColor + self.watermarkIcon = watermarkIcon + self.type = type + self.firstNodeId = firstNodeId + self.firstNodeOrder = firstNodeOrder + self.isPortrait = isPortrait + self.status = status + self.progress = progress + self.totalNodes = totalNodes + self.completedNodes = completedNodes + } + + // ✅ 修复:删除手动实现的 Hashable 和 Equatable + // Swift 会自动合成,比较所有存储属性(包括 title、coverImage、status、progress 等) + // 这样当这些属性变化时,SwiftUI 会正确检测到变化并更新视图 +} + +// MARK: - 模拟数据 (用于开发预览) +extension Course { + static let mocks: [Course] = [ + Course( + id: "c1", + title: "重塑抗压力", + description: "情绪急救与心理复原力构建。", + coverImage: "leaf.fill", // 暂时用 SF Symbol 名代替图片 URL + type: .system, + firstNodeId: "node_1", + firstNodeOrder: 0, + status: "in_progress", // ✅ 修复:改为字符串 + progress: 35, + totalNodes: 10, + completedNodes: 4 + ), + Course( + id: "c2", + title: "职场向上管理", + description: "不要让老板成为你的天花板。学会用资源办自己的事。", + coverImage: "briefcase.fill", + type: .system, + firstNodeId: "node_2", + firstNodeOrder: 0, + status: "not_started", // ✅ 修复:改为字符串 + progress: 0, + totalNodes: 8, + completedNodes: 0 + ), + Course( + id: "c3", + title: "打破拖延闭环", + description: "完成于 12月01日", + coverImage: "checkmark.circle.fill", + type: .system, + firstNodeId: "node_3", + firstNodeOrder: 0, + status: "completed", // ✅ 修复:改为字符串 + progress: 100, + totalNodes: 5, + completedNodes: 5 + ), + // ✅ 新增:小节课 Mock 数据 + Course( + id: "course_single_1", + title: "5分钟时间管理", + description: "快速掌握番茄工作法。", + coverImage: "clock.fill", + type: .single, + firstNodeId: "node_single_1", + firstNodeOrder: 0, + status: "not_started", // ✅ 修复:改为字符串 + progress: 0, + totalNodes: 1, + completedNodes: 0 + ) + ] +} + +// ✅ AI 生成相关枚举已删除 + +// MARK: - 已加入课程模型 +struct JoinedCourse: Identifiable, Codable, Equatable { + let id: String + let title: String + let description: String? + let coverImage: String? // ✅ 修正:改为可选,因为后端可能返回 null + let themeColor: String? // ✅ 新增:主题色 Hex + let watermarkIcon: String? // ✅ 新增:水印图标名称(SF Symbol) + let type: String? // ✅ 改为可选,因为生成中的课程可能没有 + let isPortrait: Bool // ✅ 新增:是否为竖屏课程 + let status: String // ✅ 改为String,因为可能返回"generating"等状态 + let progress: Int + let totalNodes: Int? + let completedNodes: Int? + let lastOpenedAt: String? // ✅ 改为String,后端返回ISO字符串 + let joinedAt: String? // ✅ 改为可选String,生成中的课程可能没有 + + // ✅ 课程创建者ID,用于判断是否可以修改 + let createdBy: String? // 如果为当前用户ID,则可以修改课程名称 + + // ✅ 生成任务相关字段 + let isGenerating: Bool? // 是否正在生成中 + let generationStatus: String? // 生成状态(pending, content_generating, completed, failed) + let generationProgress: Int? // 生成进度(0-100) + let generationTaskId: String? // ✅ 新增:生成任务ID(用于跳转到创建进度页) + + enum CodingKeys: String, CodingKey { + case id, title, description, type, status, progress + case coverImage = "cover_image" + case themeColor = "theme_color" + case watermarkIcon = "watermark_icon" + case totalNodes = "total_nodes" + case completedNodes = "completed_nodes" + case lastOpenedAt = "last_opened_at" + case joinedAt = "joined_at" + case isPortrait = "is_portrait" + // ✅ 生成相关字段映射 + case createdBy = "created_by" + case isGenerating = "is_generating" + case generationStatus = "generation_status" + case generationProgress = "generation_progress" + case generationTaskId = "generation_task_id" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + description = try container.decodeIfPresent(String.self, forKey: .description) + coverImage = try container.decodeIfPresent(String.self, forKey: .coverImage) + themeColor = try container.decodeIfPresent(String.self, forKey: .themeColor) // ✅ 新增 + watermarkIcon = try container.decodeIfPresent(String.self, forKey: .watermarkIcon) // ✅ 新增 + type = try container.decodeIfPresent(String.self, forKey: .type) + isPortrait = try container.decodeIfPresent(Bool.self, forKey: .isPortrait) ?? false + status = try container.decode(String.self, forKey: .status) + progress = try container.decode(Int.self, forKey: .progress) + totalNodes = try container.decodeIfPresent(Int.self, forKey: .totalNodes) + completedNodes = try container.decodeIfPresent(Int.self, forKey: .completedNodes) + lastOpenedAt = try container.decodeIfPresent(String.self, forKey: .lastOpenedAt) + joinedAt = try container.decodeIfPresent(String.self, forKey: .joinedAt) + + // ✅ 课程创建者ID + createdBy = try container.decodeIfPresent(String.self, forKey: .createdBy) + + // ✅ 生成任务相关字段 + isGenerating = try container.decodeIfPresent(Bool.self, forKey: .isGenerating) + generationStatus = try container.decodeIfPresent(String.self, forKey: .generationStatus) + generationProgress = try container.decodeIfPresent(Int.self, forKey: .generationProgress) + generationTaskId = try container.decodeIfPresent(String.self, forKey: .generationTaskId) + } + + // ✅ 标准初始化器 (用于创建占位卡片) + init( + id: String, + title: String, + description: String? = nil, + coverImage: String? = nil, + themeColor: String? = nil, // ✅ 新增 + watermarkIcon: String? = nil, // ✅ 新增 + type: String? = nil, + isPortrait: Bool = false, + status: String, + progress: Int, + totalNodes: Int? = nil, + completedNodes: Int? = nil, + lastOpenedAt: String? = nil, + joinedAt: String? = nil, + createdBy: String? = nil, + isGenerating: Bool? = nil, + generationStatus: String? = nil, + generationProgress: Int? = nil, + generationTaskId: String? = nil + ) { + self.id = id + self.title = title + self.description = description + self.coverImage = coverImage + self.themeColor = themeColor + self.watermarkIcon = watermarkIcon + self.type = type + self.isPortrait = isPortrait + self.status = status + self.progress = progress + self.totalNodes = totalNodes + self.completedNodes = completedNodes + self.lastOpenedAt = lastOpenedAt + self.joinedAt = joinedAt + self.createdBy = createdBy + self.isGenerating = isGenerating + self.generationStatus = generationStatus + self.generationProgress = generationProgress + self.generationTaskId = generationTaskId + } + + // ✅ 兼容旧的状态判断 + var courseStatus: CourseStatus { + switch status { + case "completed": return .completed + case "in_progress": return .inProgress + case "generating": return .inProgress // 生成中视为进行中 + default: return .notStarted + } + } + + // MARK: - UI 计算属性 + + /// 状态文本:只有已完成时显示(与发现页逻辑一致) + var statusText: String? { + switch courseStatus { + case .completed: + return "已完成" + case .inProgress, .notStarted, .generating: + return nil + } + } + + /// 水印图标:优先使用存储的值,否则根据类型映射 + var watermarkIconName: String { + // ✅ 优先使用存储的水印图标 + if let storedIcon = watermarkIcon, !storedIcon.isEmpty { + return storedIcon + } + // 向后兼容:根据类型计算 + guard let type = type else { + return "doc.text.fill" // 默认文档图标 + } + switch type { + case "system": + return "book.closed.fill" + case "single": + return "doc.text.fill" + case "vertical_screen": + return "rectangle.portrait.fill" + default: + return "doc.text.fill" // 兜底 + } + } + + /// 是否正在生成中(兼容旧逻辑) + var isGeneratingStatus: Bool { + // ✅ 优先使用后端返回的 isGenerating 字段 + if let isGen = isGenerating { + return isGen + } + // 向后兼容:根据 status 判断是否在生成中 + // 生成状态包括:pending, mode_selected, outline_generating, outline_completed, content_generating + let generatingStatuses = ["pending", "mode_selected", "outline_generating", "outline_completed", "content_generating", "generating"] + return generatingStatuses.contains(status) || generatingStatuses.contains(generationStatus ?? "") + } + + /// 是否生成失败 + var isGenerationFailed: Bool { + return generationStatus == "failed" || status == "failed" + } +} + +// MARK: - 发现页运营位 +struct OperationalBannerResponse: Codable { + let success: Bool + let data: OperationalBannerData +} + +struct OperationalBannerData: Codable { + let banners: [OperationalBanner] +} + +struct OperationalBanner: Codable, Identifiable { + let id: String + let title: String + let courses: [BannerCourseItem] +} + +struct BannerCourseItem: Codable, Identifiable { + let id: String + let title: String + let coverImage: String + let themeColor: String? + let watermarkIcon: String? + enum CodingKeys: String, CodingKey { + case id + case title + case coverImage = "cover_image" + case themeColor = "theme_color" + case watermarkIcon = "watermark_icon" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + title = try c.decodeIfPresent(String.self, forKey: .title) ?? "" + coverImage = try c.decodeIfPresent(String.self, forKey: .coverImage) ?? "" + themeColor = try c.decodeIfPresent(String.self, forKey: .themeColor) + watermarkIcon = try c.decodeIfPresent(String.self, forKey: .watermarkIcon) + } +} diff --git a/ios/WildGrowth/WildGrowth/CourseNavigation.swift b/ios/WildGrowth/WildGrowth/CourseNavigation.swift new file mode 100644 index 0000000..883df30 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/CourseNavigation.swift @@ -0,0 +1,7 @@ +import Foundation + +// MARK: - 课程导航路由枚举(供 DiscoveryView / GrowthView / MapView / ProfileView 共用) +enum CourseNavigation: Hashable { + case map(courseId: String) + case player(courseId: String, nodeId: String, isLastNode: Bool, courseTitle: String?) +} diff --git a/ios/WildGrowth/WildGrowth/CourseService.swift b/ios/WildGrowth/WildGrowth/CourseService.swift new file mode 100644 index 0000000..8ae5d7a --- /dev/null +++ b/ios/WildGrowth/WildGrowth/CourseService.swift @@ -0,0 +1,83 @@ +import Foundation + +class CourseService { + static let shared = CourseService() + private let apiClient = APIClient.shared + + // 获取 App 版本号 + private var appVersion: String { + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + return version + } + return "1.1.0" // 默认版本号(与项目配置 MARKETING_VERSION 保持一致) + } + + // 获取课程列表(技能页「我的课程」等用,不含发现页专用逻辑) + func getCourses() async throws -> [Course] { + let hasToken = TokenManager.shared.getToken() != nil + let version = appVersion + let endpoint = "/api/courses?app_version=\(version)" + let response: CourseListResponse = try await apiClient.request( + endpoint: endpoint, + method: "GET", + requiresAuth: hasToken + ) + return response.data.courses + } + + // 发现页专用:排除运营位课程,按加入课程数降序(与 getCourses 返回结构一致) + func getDiscoveryFeed() async throws -> [Course] { + let hasToken = TokenManager.shared.getToken() != nil + let version = appVersion + let endpoint = "/api/courses/discovery-feed?app_version=\(version)" + let response: CourseListResponse = try await apiClient.request( + endpoint: endpoint, + method: "GET", + requiresAuth: hasToken + ) + return response.data.courses + } + + // 获取课程地图 + func getCourseMap(courseId: String) async throws -> MapData { + // ✨ 游客模式下不传 Token(支持游客浏览) + // 直接检查 Token 是否存在,而不是依赖 isLoggedIn(可能有过期 Token) + let hasToken = TokenManager.shared.getToken() != nil + let response: MapResponse = try await apiClient.request( + endpoint: "/api/courses/\(courseId)/map", + method: "GET", + requiresAuth: hasToken // 有 Token 才传,没有就不传(游客模式) + ) + return response.data + } + + // 发现页运营位(无需认证) + func getOperationalBanners() async throws -> [OperationalBanner] { + let response: OperationalBannerResponse = try await apiClient.request( + endpoint: "/api/operational-banners", + method: "GET", + requiresAuth: false + ) + return response.data.banners + } + + // 生成课程封面图 + func generateCourseCover(courseId: String) async throws -> String { + struct GenerateCoverResponse: Codable { + let success: Bool + let data: CoverData + } + + struct CoverData: Codable { + let coverImage: String + } + + let response: GenerateCoverResponse = try await apiClient.request( + endpoint: "/api/courses/\(courseId)/generate-cover", + method: "POST", + requiresAuth: true + ) + + return response.data.coverImage + } +} \ No newline at end of file diff --git a/ios/WildGrowth/WildGrowth/CourseServiceExtension.swift b/ios/WildGrowth/WildGrowth/CourseServiceExtension.swift new file mode 100644 index 0000000..d412e61 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/CourseServiceExtension.swift @@ -0,0 +1,169 @@ +import Foundation + +// ✅ AI 创建课程相关响应已删除 + +// MARK: - 已加入课程列表响应 +struct JoinedCourseListResponse: Decodable { + let success: Bool + let data: JoinedCourseListData + + struct JoinedCourseListData: Decodable { + let courses: [JoinedCourse] + } +} + +// MARK: - CourseService 扩展 (竖屏课程详情) +extension CourseService { + /// 获取课程小节详情 (竖屏课程专用) + func getLessonDetail(nodeId: String) async throws -> LessonDetail { + // 1. 检查 Token(支持游客模式) + let hasToken = TokenManager.shared.getToken() != nil + + // 2. 构造 Endpoint + let endpoint = "/api/lessons/nodes/\(nodeId)/detail" + + // 3. 发起请求(带鉴权) + // ✅ 直接使用 APIClient.shared,因为 apiClient 是 private 的 + let response: LessonDetailResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "GET", + requiresAuth: hasToken // ✅ 保持与现有代码一致 + ) + + // 4. 校验业务成功状态 + guard response.success else { + throw URLError(.badServerResponse) + } + + return response.data + } + + /// 上报学习进度 + func updateLessonProgress(nodeId: String, progress: Float) async { + let hasToken = TokenManager.shared.getToken() != nil + guard hasToken else { return } + + let endpoint = "/api/lessons/nodes/\(nodeId)/progress" + + // ⚠️ 请确认后端字段名:progress 或 scroll_offset + let body: [String: Any] = ["progress": progress] + + do { + // ✅ 使用 APIClient.shared,因为 apiClient 是 private 的 + // 使用 EmptyResponse 作为返回类型(不需要解析响应数据) + let _: EmptyResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "POST", + body: body, + requiresAuth: true + ) + #if DEBUG + print("✅ 进度保存成功: \(progress)") + #endif + } catch { + print("❌ 进度保存失败: \(error.localizedDescription)") + } + } + + /// 加入课程 + func joinCourse(courseId: String) async throws { + let endpoint = "/api/my-courses/join" + let body: [String: Any] = ["courseId": courseId] + + struct JoinResponse: Codable { + let success: Bool + let message: String? + } + + let _: JoinResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "POST", + body: body, + requiresAuth: true + ) + } + + /// 移除课程 + func removeCourse(courseId: String) async throws { + let endpoint = "/api/my-courses/remove" + let body: [String: Any] = ["courseId": courseId] + + struct RemoveResponse: Codable { + let success: Bool + let message: String? + } + + let _: RemoveResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "POST", + body: body, + requiresAuth: true + ) + } + + /// 检查课程是否已加入 + func checkIsJoined(courseId: String) async throws -> Bool { + let endpoint = "/api/my-courses/check" + let body: [String: Any] = ["courseId": courseId] + + struct CheckResponse: Codable { + let success: Bool + let data: Bool + } + + let response: CheckResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "POST", + body: body, + requiresAuth: true + ) + + return response.data + } + + // ✅ AI 创建课程、选择模式、获取任务状态相关方法已完全删除 + + // 获取已加入课程 + func fetchJoinedCourses() async throws -> [JoinedCourse] { + let endpoint = "/api/my-courses" + let response: JoinedCourseListResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "GET", + requiresAuth: true + ) + guard response.success else { throw APIError.serverError("获取课程列表失败") } + return response.data.courses + } + + // 更新最后打开时间 + func updateLastOpened(courseId: String) async throws { + let endpoint = "/api/my-courses/update-last-opened" + let body: [String: Any] = ["courseId": courseId] + struct UpdateResponse: Codable { let success: Bool } + let _: UpdateResponse = try await APIClient.shared.request( + endpoint: endpoint, + method: "POST", + body: body, + requiresAuth: true + ) + } + + // ✅ AI 重试课程生成方法已删除 + + // 更新课程名称 + func updateCourseTitle(courseId: String, title: String) async throws { + struct UpdateResponse: Codable { + let success: Bool + } + + let body: [String: Any] = ["title": title] + let _: UpdateResponse = try await APIClient.shared.request( + endpoint: "/api/courses/\(courseId)", + method: "PATCH", + body: body, + requiresAuth: true + ) + } + + // ✅ AI 取消任务方法已删除 +} diff --git a/ios/WildGrowth/WildGrowth/DesignSystem+UIKit.swift b/ios/WildGrowth/WildGrowth/DesignSystem+UIKit.swift new file mode 100644 index 0000000..4372dbc --- /dev/null +++ b/ios/WildGrowth/WildGrowth/DesignSystem+UIKit.swift @@ -0,0 +1,46 @@ +import UIKit +import SwiftUI + +// MARK: - UIColor Extensions (UIKit Support) +extension UIColor { + // 1. 核心颜色 (与 DesignSystem.swift 对齐) + static let brandVital = UIColor(hex: "2266ff") + static let inkBody = UIColor(hex: "333333") // 正文色 + static let inkPrimary = UIColor.black + static let inkSecondary = UIColor(hex: "8E8E93") + + // ✅ 新增:赛博辅助色(补全设计系统) + static let cyberIris = UIColor(hex: "8A4FFF") + static let cyberNeon = UIColor(hex: "FF4081") + + // ✅ 新增:语义化颜色 (Semantic Colors) + // 马克笔颜色:使用赛博粉,视觉更通透 + static let markerColor = UIColor.cyberNeon + + // ✅ 保留:背景高亮色(向后兼容,但推荐使用 HighlightStyle 中的定义) + static let highlightBackground = UIColor(hex: "2266ff").withAlphaComponent(0.3) + + // 2. 辅助初始化方法 + convenience init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + self.init( + red: CGFloat(r) / 255, + green: CGFloat(g) / 255, + blue: CGFloat(b) / 255, + alpha: CGFloat(a) / 255 + ) + } +} diff --git a/ios/WildGrowth/WildGrowth/DesignSystem.swift b/ios/WildGrowth/WildGrowth/DesignSystem.swift new file mode 100644 index 0000000..c3e6ced --- /dev/null +++ b/ios/WildGrowth/WildGrowth/DesignSystem.swift @@ -0,0 +1,391 @@ +import SwiftUI + +// MARK: - 🎨 Color Palette (Final Refined) + +extension Color { + // MARK: - New 2025 Cyber Palette + // 1. 功能主色 (Vital Blue) - 更新为电光蓝 + static let brandVital = Color(hex: "2266ff") + + // 2. 文字系统 (Ink System) + + // Level 1: 标题/强调 (纯黑) - 杂志感 + static let inkPrimary = Color.black + + // Level 2: 正文阅读 (微信读书风格) + // 深炭灰,比纯黑柔和,比灰色清晰,专用于长文阅读 + static let inkBody = Color(hex: "333333") + + // Level 3: 辅助/未选中 (小红书风格) + // 回归经典的实心灰,用于 Tab 未选中态、Caption、元数据 + // 之前用 opacity(0.6) 会显得脏,改回 #8E8E93 更清爽 + static let inkSecondary = Color(hex: "8E8E93") + + // 3. 背景色 + static let bgPaper = Color(hex: "FBFBFD") + static let bgWhite = Color.white + + // 4. 赛博辅助色 + static let cyberIris = Color(hex: "8A4FFF") + static let cyberNeon = Color(hex: "FF4081") + + // MARK: - Phase 3 激励系统高饱和度色 (用于经验值进度条) + static let brandOrange = Color(hex: "FF6B35") // 活力橙 + static let brandMint = Color(hex: "00D4AA") // 薄荷绿 + static let brandPink = Color(hex: "FF4081") // 樱花粉 + static let brandPurple = Color(hex: "8A4FFF") // 电光紫 + static let brandAmber = Color(hex: "FFB800") // 琥珀色 + + // ✅ 颜色池,用于循环分配(静态计算属性,确保安全访问) + static var experienceColors: [Color] { + [brandOrange, brandMint, brandPink, brandPurple, brandAmber] + } + + // MARK: - Phase 5: 电子成长专属色 (Electric Growth Exclusive) + // 微信读书风·哑光暖橙 (用于状态角标) + static let brandMutedOrange = Color(hex: "EF9233") + static let brandMutedOrangeLight = Color(hex: "F6B05E") + + // MARK: - Backward Compatibility + static var brandMoss: Color { brandVital } + static var brandClay: Color { cyberNeon } + static var brandRose: Color { cyberNeon } + static var brandSand: Color { bgPaper } + + // MARK: - Helper + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// MARK: - 🌈 Gradients + +extension ShapeStyle where Self == LinearGradient { + // 现有的渐变 + static var cyberGradient: LinearGradient { + LinearGradient( + colors: [Color.cyberIris, Color.cyberNeon], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // ✅ Phase 4.4: 新增笔记本封面渐变 (深邃赛博感) + static var notebookCoverCyber: LinearGradient { + LinearGradient( + colors: [ + Color.brandVital, // 电光蓝 + Color(hex: "1A4BD6"), // 深一点的蓝 + Color.cyberIris.opacity(0.8) // 尾部带一点紫 + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // ✅ Phase 4.4: 新增按钮/高亮渐变 + static var cyberButton: LinearGradient { + LinearGradient( + colors: [Color.brandVital, Color.brandVital.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + } + + // MARK: - Phase 5: 电子成长课程卡片背景流体 (Electric Growth Course Card Fluids) + + // 主题 1: 电光蓝 (品牌蓝) + static var electricGrowth: LinearGradient { + LinearGradient( + colors: [Color.brandVital, Color(hex: "4080ff")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // 主题 2: Duolingo Feather Green + static var duolingoGreenGrowth: LinearGradient { + LinearGradient( + colors: [Color(hex: "58CC02"), Color(hex: "89E219")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // 主题 3: 电光紫 Cyber Iris + static var duolingoPurpleGrowth: LinearGradient { + LinearGradient( + colors: [Color(hex: "8A4FFF"), Color(hex: "A870FF")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // 主题 4: Duolingo Cardinal Red + static var duolingoRedGrowth: LinearGradient { + LinearGradient( + colors: [Color(hex: "FF4B4B"), Color(hex: "FF6B6B")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // 主题 5: Duolingo Bee Yellow + static var duolingoYellowGrowth: LinearGradient { + LinearGradient( + colors: [Color(hex: "FFC800"), Color(hex: "FFE040")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // 主题 6: Duolingo Fox Orange + static var duolingoOrangeGrowth: LinearGradient { + LinearGradient( + colors: [Color(hex: "FF9600"), Color(hex: "FFB340")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // 状态角标渐变 (微信读书风·哑光暖橙) + static var statusRibbonGradient: LinearGradient { + LinearGradient( + colors: [Color.brandMutedOrangeLight, Color.brandMutedOrange], + startPoint: .top, + endPoint: .bottom + ) + } + + // ✅ 课程卡片渐变池:品牌蓝 + 多邻国绿/紫/红/黄/橙(design.duolingo.com) + static var courseCardGradients: [LinearGradient] { + [electricGrowth, duolingoGreenGrowth, duolingoPurpleGrowth, duolingoRedGrowth, duolingoYellowGrowth, duolingoOrangeGrowth] + } +} + +// MARK: - 🔠 Typography (Final V3.1) + +extension Font { + // 1. 总结页大标题 (视觉降噪) + static let summaryTitle = system(size: 22, weight: .bold) + + // 2. 卡片主标题 (放大版) + static let cardTitle = system(size: 24, weight: .bold) + + // 3. 卡片副标题/进度 (放大版) + static let cardSubtitle = system(size: 16, weight: .medium) + + // 4. 地图节点标题 (放大版) + static let nodeTitle = system(size: 18, weight: .semibold) + + // 5. Tab Bar (视觉降噪) - ✅ 修改现有定义,从 .heavy 改为 .bold + static let tabActive = system(size: 18, weight: .bold) + static let tabInactive = system(size: 16, weight: .medium) + + // 6. 正文阅读 (沉浸版) + static let bodyReading = system(size: 19, weight: .regular) + + // 保留现有定义(向后兼容) + static let display24Black = system(size: 24, weight: .black) + static let heading22Bold = system(size: 22, weight: .bold) + static let body17 = system(size: 17, weight: .regular) + + // Backward Compatibility + static func displayTitle() -> Font { .system(size: 28, weight: .bold) } + static func heading() -> Font { .system(size: 20, weight: .semibold) } + static func bodyText() -> Font { .system(size: 16, weight: .regular) } + static func captionText() -> Font { .system(size: 14, weight: .regular) } + + // MARK: - Design System 2.0 (Typography 2026) + // 审查确认:无硬编码,语义化命名,结构正确 + + /// 一级大数字展示 (用于:个人中心核心数据、成就数值) + /// 32pt / Semibold / Rounded + static func statDisplayLarge() -> Font { + .system(size: 32, weight: .semibold, design: .rounded) + } + + /// 二级数字展示 (用于:卡片内数据、次级统计) + /// 24pt / Semibold / Rounded + static func statDisplayMedium() -> Font { + .system(size: 24, weight: .semibold, design: .rounded) + } + + /// 数字 ID 专用 (用于:电子证 ID、序列号,保留极客感) + /// 18pt / Medium / Monospaced (等宽,不使用 rounded) + static func statDigitalID() -> Font { + .system(size: 18, weight: .medium, design: .monospaced) + } + + // MARK: - 文本层级系统 + + /// 二级标题 (用于:模块标题、卡片主标题、昵称) + /// 18pt / Semibold + static func h2() -> Font { + .system(size: 18, weight: .semibold) + } + + /// 三级标题 (用于:列表项标题、Tab 文字) + /// 16pt / Medium + static func h3() -> Font { + .system(size: 16, weight: .medium) + } + + /// 正文 (用于:输入框、普通文本) + /// 15pt / Regular + static func body() -> Font { + .system(size: 15, weight: .regular) + } + + /// 辅助标签 - 常规 (用于:单位、次要说明) + /// 13pt / Regular + static func label() -> Font { + .system(size: 13, weight: .regular) + } + + /// 辅助标签 - 强调 (用于:统计项标题) + /// 13pt / Medium + static func labelMedium() -> Font { + .system(size: 13, weight: .medium) + } + + /// 按钮文字 (用于:主要操作按钮) + /// 16pt / Semibold + static func buttonPrimary() -> Font { + .system(size: 16, weight: .semibold) + } +} + +// MARK: - Modifiers (UI Consistency) + +struct HairlineBorderModifier: ViewModifier { + var radius: CGFloat + + func body(content: Content) -> some View { + content.overlay( + RoundedRectangle(cornerRadius: radius) + .stroke(Color.black.opacity(0.05), lineWidth: 0.5) + ) + } +} + +struct FrostedHeaderModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background( + Rectangle() + .fill(.ultraThinMaterial) + .edgesIgnoringSafeArea(.top) + ) + .overlay( + VStack { + Spacer() + Divider() + } + ) + } +} + +// ✅ Phase 4.4: 新增赛博发光阴影修饰符 +struct CyberShadowModifier: ViewModifier { + var radius: CGFloat = 8 + var y: CGFloat = 4 + var opacity: Double = 0.15 + + func body(content: Content) -> some View { + content.shadow( + color: Color.brandVital.opacity(opacity), + radius: radius, + x: 0, + y: y + ) + } +} + +// ✅ Phase 4.4: 新增通用卡片样式修饰符 +struct CardStyleModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Color.bgWhite) + .cornerRadius(12) + // 统一使用微弱的黑色阴影增加悬浮感 + .shadow(color: Color.black.opacity(0.04), radius: 10, x: 0, y: 2) + } +} + +extension View { + // 现有的扩展方法 (保留) + func hairlineBorder(radius: CGFloat = 12) -> some View { + self.modifier(HairlineBorderModifier(radius: radius)) + } + + func frostedHeader() -> some View { + self.modifier(FrostedHeaderModifier()) + } + + // ✅ Phase 4.4: 新增快速调用方法 + + // 赛博发光阴影 + func cyberShadow(radius: CGFloat = 8, y: CGFloat = 4, opacity: Double = 0.15) -> some View { + self.modifier(CyberShadowModifier(radius: radius, y: y, opacity: opacity)) + } + + // 通用卡片样式 + func cardStyle() -> some View { + self.modifier(CardStyleModifier()) + } +} + +// MARK: - 课程封面统一占位图(无图/失败/占位时使用) +struct CourseCoverPlaceholderView: View { + var body: some View { + ZStack { + Color.bgPaper + Image(systemName: "photo") + .font(.system(size: 48)) + .foregroundColor(.inkSecondary.opacity(0.3)) + } + } +} + +// ✅ 共享组件:VisualEffectBlur +struct VisualEffectBlur: UIViewRepresentable { + var blurStyle: UIBlurEffect.Style + func makeUIView(context: Context) -> UIVisualEffectView { + return UIVisualEffectView(effect: UIBlurEffect(style: blurStyle)) + } + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: blurStyle) + } +} + +// MARK: - iPad 适配修饰符 +struct iPadSheetAdaptationModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content.presentationCompactAdaptation(.sheet) + } else { + content + } + } +} diff --git a/ios/WildGrowth/WildGrowth/GlobalStyles.swift b/ios/WildGrowth/WildGrowth/GlobalStyles.swift new file mode 100644 index 0000000..15ac2da --- /dev/null +++ b/ios/WildGrowth/WildGrowth/GlobalStyles.swift @@ -0,0 +1,51 @@ +import SwiftUI + +// ✅ 统一的全局定义 +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} + +// ✅ 圆角扩展(全局可用) +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +// ✅ 独立的日期工具,带版本检查和错误日志 +struct ISO8601DateFormatters { + static let full: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + if #available(iOS 11.0, *) { + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + } else { + formatter.formatOptions = [.withInternetDateTime] + } + return formatter + }() + + static let simple: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + // ✅ 辅助解析方法 + static func date(from string: String) -> Date? { + if let date = full.date(from: string) { return date } + if let date = simple.date(from: string) { return date } + print("⚠️ [DateFormatter] 解析失败: \(string)") + print(" 尝试的格式: [.withInternetDateTime, .withFractionalSeconds] 和 [.withInternetDateTime]") + return nil + } +} diff --git a/ios/WildGrowth/WildGrowth/HighlightStyle.swift b/ios/WildGrowth/WildGrowth/HighlightStyle.swift new file mode 100644 index 0000000..34b5dd5 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/HighlightStyle.swift @@ -0,0 +1,43 @@ +import UIKit + +struct HighlightStyle { + + // 🎨 样式 A:纯划线 (Highlight) + // 修改:使用蓝色背景 + static let highlightAttributes: [NSAttributedString.Key: Any] = [ + // 背景色:蓝色荧光笔质感,透明度 0.2 + .backgroundColor: UIColor.brandVital.withAlphaComponent(0.2), + // 文字色:保持纯黑,阅读最清晰 + .foregroundColor: UIColor.inkPrimary + ] + + // 🎨 样式 B:灵动思考 (Smart Thought) -> 对应"写想法" + // 修改:去掉背景,改为粉色虚线(更短更密的点线) + static let thoughtAttributes: [NSAttributedString.Key: Any] = { + // ✅ 优化:使用 patternDot 代替 patternDash,获得更短更密的线段(点线效果) + let underlineStyle = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue + + return [ + .underlineStyle: underlineStyle, + // 下划线颜色:粉色 (Cyber Neon) + .underlineColor: UIColor.markerColor + // ✅ 不设置 foregroundColor,保留原有文字颜色 + // ✅ 不设置 backgroundColor,让系统使用默认(透明) + // 如果用户同时有划线,会在 updateHighlights 中叠加显示 + ] + }() + + // ✅ 新增:合并样式(用于同时有划线和想法的情况) + // 返回:蓝色背景 + 粉色虚线 + static func combinedAttributes() -> [NSAttributedString.Key: Any] { + // ✅ 优化:使用 patternDot 代替 patternDash,获得更短更密的线段 + let underlineStyle = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue + + return [ + .backgroundColor: UIColor.brandVital.withAlphaComponent(0.2), + .underlineStyle: underlineStyle, + .underlineColor: UIColor.markerColor + // ✅ 不设置 foregroundColor,保留原有文字颜色 + ] + } +} diff --git a/ios/WildGrowth/WildGrowth/LearningService.swift b/ios/WildGrowth/WildGrowth/LearningService.swift new file mode 100644 index 0000000..7127fa2 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/LearningService.swift @@ -0,0 +1,65 @@ +import Foundation + +class LearningService { + static let shared = LearningService() + private let apiClient = APIClient.shared + + // 获取内容 + func getNodeContent(nodeId: String) async throws -> LessonData { + // ✨ 游客模式下不传 Token(支持游客浏览) + // 直接检查 Token 是否存在,而不是依赖 isLoggedIn(可能有过期 Token) + let hasToken = TokenManager.shared.getToken() != nil + let response: LessonResponse = try await apiClient.request( + endpoint: "/api/lessons/\(nodeId)/content", + method: "GET", + requiresAuth: hasToken // 有 Token 才传,没有就不传(游客模式) + ) + + return response.data + } + + // 开始学习 + func startLesson(nodeId: String) async throws { + let _: StartLessonResponse = try await apiClient.request( + endpoint: "/api/lessons/\(nodeId)/start", + method: "POST", + requiresAuth: true + ) + } + + // 上报进度 + func updateProgress(nodeId: String, currentSlide: Int, studyTime: Int) async { + do { + let _: SimpleResponse = try await apiClient.request( + endpoint: "/api/lessons/\(nodeId)/progress", + method: "POST", + body: ["current_slide": currentSlide, "study_time": studyTime], + requiresAuth: true + ) + } catch { + print("Progress update failed: \(error)") + } + } + + // 完成学习 + func completeLesson(nodeId: String, totalStudyTime: Int, completedSlides: Int) async throws -> CompleteLessonData { + let response: CompleteLessonResponse = try await apiClient.request( + endpoint: "/api/lessons/\(nodeId)/complete", + method: "POST", + body: ["total_study_time": totalStudyTime, "completed_slides": completedSlides], + requiresAuth: true + ) + return response.data + } + + // 获取总结 + func getLessonSummary(nodeId: String) async throws -> LessonSummaryData { + let response: LessonSummaryResponse = try await apiClient.request( + endpoint: "/api/lessons/\(nodeId)/summary", + method: "GET", + requiresAuth: true + ) + return response.data + } +} + diff --git a/ios/WildGrowth/WildGrowth/LessonDetail.swift b/ios/WildGrowth/WildGrowth/LessonDetail.swift new file mode 100644 index 0000000..fda6f70 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/LessonDetail.swift @@ -0,0 +1,64 @@ +import Foundation + +// MARK: - API 响应结构 +struct LessonDetailResponse: Codable { + let success: Bool + let data: LessonDetail +} + +// MARK: - 课程详情模型 +struct LessonDetail: Codable { + let nodeId: String + let title: String + let author: String + let intro: String + let blocks: [ContentBlock] + + // ✅ 新增:上次阅读进度 (0.0 - 1.0) + let lastProgress: Float? + + enum CodingKeys: String, CodingKey { + case nodeId = "node_id" + case title + case author + case intro + case blocks + case lastProgress = "last_progress" + } +} + +// MARK: - 内容块定义 +enum ContentBlockType: String, Codable { + case heading1 // H1 + case heading2 // H2 + case paragraph // 正文 (Markdown) + case quote // 引用 (Markdown) + case highlight // 金句 (Markdown) + case list // 列表 +} + +struct ContentBlock: Identifiable, Codable { + let id: String + let type: ContentBlockType + let content: String + + // 自动生成 ID 的容错解码 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(ContentBlockType.self, forKey: .type) + self.content = try container.decode(String.self, forKey: .content) + // 如果后端没传 id,前端自动生成 UUID + self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + } + + // Mock 构造器 + init(id: String = UUID().uuidString, type: ContentBlockType, content: String) { + self.id = id + self.type = type + self.content = content + } + + enum CodingKeys: String, CodingKey { + case id, type, content + } +} diff --git a/ios/WildGrowth/WildGrowth/LessonModels.swift b/ios/WildGrowth/WildGrowth/LessonModels.swift new file mode 100644 index 0000000..29aaa81 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/LessonModels.swift @@ -0,0 +1,131 @@ +import Foundation + +// MARK: - 课程内容响应 + +struct LessonResponse: Codable { + let success: Bool + let data: LessonData +} + +struct LessonData: Codable { + let nodeId: String + let nodeTitle: String + let totalSlides: Int + let completionRate: Int? // 【新增】后端返回当前进度 + let slides: [Slide] + + enum CodingKeys: String, CodingKey { + case nodeId = "node_id" + case nodeTitle = "node_title" + case totalSlides = "total_slides" + case completionRate = "completion_rate" + case slides + } +} + +// 幻灯片模型 +struct Slide: Codable, Identifiable { + let id: String + let type: String + let order: Int + let content: SlideContent + let effect: String? + let interaction: String? + + enum CodingKeys: String, CodingKey { + case id, type, order, content, effect, interaction + } +} + +enum ImagePosition: String, Codable { + case top, middle, bottom +} + +struct SlideContent: Codable { + let title: String? + let paragraphs: [String]? + let imageUrl: String? + let highlightKeywords: [String]? + let imagePosition: ImagePosition? + + enum CodingKeys: String, CodingKey { + case title, paragraphs + case imageUrl = "image_url" + case highlightKeywords = "highlight_keywords" + case imagePosition = "image_position" + } +} + +// MARK: - 学习总结响应 + +struct LessonSummaryResponse: Codable { + let success: Bool + let data: LessonSummaryData +} + +struct LessonSummaryData: Codable { + let nodeId: String + let nodeTitle: String + let summary: SummaryInfo + let achievement: AchievementInfo + + enum CodingKeys: String, CodingKey { + case nodeId = "node_id" + case nodeTitle = "node_title" + case summary, achievement + } +} + +struct SummaryInfo: Codable { + let lessonNumber: Int + let studyTime: Int + let completionRate: Int + let totalSlides: Int + let completedSlides: Int + + enum CodingKeys: String, CodingKey { + case lessonNumber = "lesson_number" + case studyTime = "study_time" + case completionRate = "completion_rate" + case totalSlides = "total_slides" + case completedSlides = "completed_slides" + } +} + +struct AchievementInfo: Codable { + let unlocked: Bool + let badge: String +} + +// MARK: - 学习控制响应 + +struct StartLessonResponse: Codable { + let success: Bool + let data: StartLessonData +} + +struct StartLessonData: Codable { + let startedAt: String + + enum CodingKeys: String, CodingKey { + case startedAt = "started_at" + } +} + +struct CompleteLessonResponse: Codable { + let success: Bool + let data: CompleteLessonData +} + +struct CompleteLessonData: Codable { + let unlockedNext: Bool + let nextNodeId: String? + + enum CodingKeys: String, CodingKey { + case unlockedNext = "unlocked_next" + case nextNodeId = "next_node_id" + } +} + +// 空响应 +struct EmptyResponse: Codable {} diff --git a/ios/WildGrowth/WildGrowth/LoginView.swift b/ios/WildGrowth/WildGrowth/LoginView.swift new file mode 100644 index 0000000..e8fcee5 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/LoginView.swift @@ -0,0 +1,523 @@ +import SwiftUI +import AuthenticationServices // 引入 Apple 登录支持 +import SafariServices // 引入 Safari 视图支持(用于显示协议链接) + +struct LoginView: View { + @Binding var isLoggedIn: Bool + @Environment(\.dismiss) var dismiss // 保持原有的 + + // ✅ 新增:接收外部传入的关闭指令 + // 如果这个值存在,说明现在是"浮窗模式" + var onClose: (() -> Void)? = nil + + @State private var isAgreed = false + @State private var showPhoneInput = false + @State private var showShakeAnimation = false + @State private var isAppleLoginLoading = false // ✅ 新增:Apple 登录 loading 状态 + @State private var appleLoginError: String? // ✅ 新增:Apple 登录错误提示 + @State private var showUserAgreement = false // ✅ 新增:用户协议 Safari 视图状态 + @State private var showPrivacyPolicy = false // ✅ 新增:隐私政策 Safari 视图状态 + + var body: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + if showPhoneInput { + PhoneInputView( + isLoggedIn: $isLoggedIn, + onBack: { withAnimation { showPhoneInput = false } }, + onClose: onClose // ✅ 传递关闭回调,登录成功后自动关闭浮窗 + ) + .transition(.move(edge: .trailing)) + } else { + welcomeContent + .transition(.move(edge: .leading)) + } + } + .onAppear { + AnalyticsManager.shared.track("login_page_view") + } + .animation(.spring(response: 0.5, dampingFraction: 0.8), value: showPhoneInput) + // ✅ 核心修复 1:条件渲染关闭按钮 + .overlay(alignment: .topLeading) { + // 只有在【非】手机号输入页时,才显示关闭浮窗的 X + if !showPhoneInput { + Button(action: { + if let onClose = onClose { + // 🅰️ 浮窗模式:执行外部指令(比如关闭浮层) + onClose() + } else { + // 🅱️ 全屏模式:执行系统返回 + dismiss() + } + }) { + Image(systemName: "xmark") + // 参照标准:size 18, weight medium + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.inkSecondary) + .padding(12) // 内部 padding + .background(Color.white.opacity(0.01)) // 扩大点击区域 + } + .padding(.top, 50) // 外部 top padding + .padding(.leading, 16) + } + } + } + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + // MARK: - 欢迎页 (Welcome) + var welcomeContent: some View { + // ✅ iPad 适配:限制最大宽度,居中显示 + HStack { + Spacer() + VStack(spacing: 0) { + Spacer() + + // 1. Brand (修改区域) + VStack(spacing: 24) { + // Icon: 替换为 AppLogo + Image("AppLogo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 120, height: 120) // 尺寸加大 + .shadow(color: Color.brandVital.opacity(0.5), radius: 20, x: 0, y: 10) // 蓝色光晕 + + VStack(spacing: 12) { + // Title: 电子成长 + Text("电子成长") + .font(.system(size: 40, weight: .bold)) // 降级为 Bold + .foregroundColor(.inkPrimary) + .tracking(8) // 增加呼吸感 + + // Slogan + Text("无痛学习的魔法") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.inkSecondary) + .multilineTextAlignment(.center) + .tracking(2) + } + } + .padding(.top, 60) + + Spacer() + + // 2. Actions + VStack(spacing: 32) { + // Agreement (可点击链接) + HStack(spacing: 4) { + Button(action: { withAnimation { isAgreed.toggle() } }) { + Image(systemName: isAgreed ? "checkmark.square.fill" : "square") + .font(.system(size: 20)) + // ✅ 颜色修改:brandMoss -> brandVital + .foregroundColor(isAgreed ? .brandVital : .inkSecondary.opacity(0.5)) + } + + // 协议文本组合(可点击链接) + Group { + Text("我已阅读并同意") + .font(.caption) + .foregroundColor(.inkSecondary) + + Button("《用户协议》") { + showUserAgreement = true + } + .font(.caption) + .foregroundColor(.brandVital) + + Text("和") + .font(.caption) + .foregroundColor(.inkSecondary) + + Button("《隐私政策》") { + showPrivacyPolicy = true + } + .font(.caption) + .foregroundColor(.brandVital) + } + } + .offset(x: showShakeAnimation ? 10 : 0) + .sheet(isPresented: $showUserAgreement) { + SafariView(url: URL(string: "https://muststudy.xin/user-agreement.html")!) + } + .sheet(isPresented: $showPrivacyPolicy) { + SafariView(url: URL(string: "https://muststudy.xin/privacy-policy.html")!) + } + + // Buttons + VStack(spacing: 32) { + // Phone Login + Button(action: { + AnalyticsManager.shared.track("login_attempt", properties: ["method": "phone"]) + if validateAgreement() { + withAnimation { showPhoneInput = true } + } + }) { + Text("手机号登录") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + // ✅ 颜色修改:brandMoss -> brandVital + .background(Color.brandVital) + .cornerRadius(28) + // ✅ 阴影修改:brandMoss -> brandVital + .shadow(color: Color.brandVital.opacity(0.4), radius: 10, x: 0, y: 5) + } + + // Apple Login + // 使用 overlay 覆盖系统 Apple 按钮逻辑,保持自定义 UI 样式 + ZStack { + // 自定义按钮 UI + Button(action: { + // 仅用于触发 Agreement 检查,实际点击由下方的 overlay 处理 + if !validateAgreement() { return } + }) { + ZStack { + Circle().fill(Color.white).frame(width: 50, height: 50).shadow(color: Color.black.opacity(0.05), radius: 8, x: 0, y: 2) + if isAppleLoginLoading { + ProgressView().tint(.inkPrimary) // ✅ 显示 loading + } else { + Image(systemName: "applelogo").font(.system(size: 22)).foregroundColor(.inkPrimary).padding(.bottom, 2) + } + } + } + .disabled(isAppleLoginLoading) // ✅ 登录中禁用按钮 + + // 只有勾选协议后,才允许点击真正的 Apple 登录 + if isAgreed && !isAppleLoginLoading { + SignInWithAppleButton(.signIn, onRequest: { request in + print("🍎 [Apple Login] 用户点击了 Apple 登录按钮") + AnalyticsManager.shared.track("login_attempt", properties: ["method": "apple"]) + request.requestedScopes = [.fullName, .email] + }, onCompletion: { result in + // ✅ 修复:直接调用,SignInWithAppleButton 的 onCompletion 本身就在主线程 + print("🍎 [Apple Login] onCompletion 回调被触发") + self.handleAppleLogin(result) + }) + .blendMode(.destinationOver) // 隐藏系统按钮UI,只保留点击功能 + .frame(width: 50, height: 50) // 与自定义按钮尺寸一致 + .clipShape(Circle()) + .opacity(0.011) // 几乎透明,覆盖在自定义按钮上 + .allowsHitTesting(true) // 允许点击 + .fixedSize() // 固定尺寸,避免约束冲突 + } + } + + // ✅ 新增:显示 Apple 登录错误提示 + if let error = appleLoginError { + Text(error) + .font(.caption) + .foregroundColor(.red) + .padding(.top, 8) + } + } + } + .padding(.horizontal, horizontalSizeClass == .regular ? 60 : 32) // ✅ iPad 增加横向 padding + .padding(.bottom, horizontalSizeClass == .regular ? 80 : 60) // ✅ iPad 增加底部 padding + } + .frame(maxWidth: horizontalSizeClass == .regular ? 500 : .infinity) // ✅ iPad 限制最大宽度 + Spacer() + } + } + + // Apple 登录回调处理 + // ✅ 修复:SignInWithAppleButton 的 onCompletion 回调本身就在主线程执行,不需要额外包装 + func handleAppleLogin(_ result: Result) { + print("🍎 [Apple Login] 开始处理登录回调") + + switch result { + case .success(let auth): + print("🍎 [Apple Login] Apple 授权成功") + + // ✅ 立即更新 UI 状态,显示 loading + isAppleLoginLoading = true + appleLoginError = nil + print("🍎 [Apple Login] 设置 loading 状态为 true") + + guard let credential = auth.credential as? ASAuthorizationAppleIDCredential, + let idToken = credential.identityToken, + let authCode = credential.authorizationCode else { + print("🍎 [Apple Login] ❌ 凭证解析失败:无法获取 identityToken 或 authorizationCode") + isAppleLoginLoading = false + appleLoginError = "获取登录信息失败,请重试" + return + } + + let tokenStr = String(data: idToken, encoding: .utf8) ?? "" + let codeStr = String(data: authCode, encoding: .utf8) ?? "" + + print("🍎 [Apple Login] 获取到凭证") + print("🍎 [Apple Login] Identity Token 长度: \(tokenStr.count)") + print("🍎 [Apple Login] Auth Code 长度: \(codeStr.count)") + + // ✅ 在后台线程执行网络请求,避免阻塞 UI + Task.detached(priority: .userInitiated) { + print("🍎 [Apple Login] 开始调用后端 API(后台线程)") + do { + let data = try await AuthService.shared.appleLogin(identityToken: tokenStr, authCode: codeStr) + print("🍎 [Apple Login] 后端 API 调用成功") + + // ✅ 回到主线程更新 UI + await MainActor.run { + print("🍎 [Apple Login] 更新用户状态(主线程)") + AnalyticsManager.shared.track("login_success", properties: ["method": "apple"]) + UserManager.shared.handleLoginSuccess(data) + isAppleLoginLoading = false + appleLoginError = nil + // ✅ 登录成功后,如果是在浮窗模式,自动关闭浮窗 + if let onClose = onClose { + print("🍎 [Apple Login] 登录成功,自动关闭浮窗") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + onClose() + } + } + print("🍎 [Apple Login] 登录流程完成") + } + } catch { + print("🍎 [Apple Login] 后端 API 调用失败: \(error)") + // ✅ 错误处理:显示错误信息(回到主线程) + await MainActor.run { + AnalyticsManager.shared.track("login_fail", properties: ["method": "apple", "error": error.localizedDescription]) + isAppleLoginLoading = false + if let apiError = error as? APIError { + switch apiError { + case .serverError(let message): + appleLoginError = message + case .unauthorized: + appleLoginError = "登录失败,请重试" + case .decodingError: + appleLoginError = "数据解析失败,请重试" + case .invalidURL: + appleLoginError = "服务器地址无效" + case .noData: + appleLoginError = "服务器未返回数据" + case .unknown(let underlyingError): + // 检查是否是网络错误 + let nsError = underlyingError as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut: + appleLoginError = "请求超时,请检查网络连接" + case NSURLErrorNotConnectedToInternet: + appleLoginError = "网络连接失败,请检查网络" + case NSURLErrorNetworkConnectionLost: + appleLoginError = "网络连接中断,请重试" + default: + appleLoginError = "网络错误:\(underlyingError.localizedDescription)" + } + } else { + appleLoginError = "登录失败:\(underlyingError.localizedDescription)" + } + } + } else { + // 处理其他类型的错误(如网络超时) + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut: + appleLoginError = "请求超时,请检查网络连接" + case NSURLErrorNotConnectedToInternet: + appleLoginError = "网络连接失败,请检查网络" + default: + appleLoginError = "网络错误:\(error.localizedDescription)" + } + } else { + appleLoginError = "登录失败:\(error.localizedDescription)" + } + } + print("🍎 [Apple Login] ❌ 错误已显示给用户: \(appleLoginError ?? "未知错误")") + } + } + } + case .failure(let error): + // ✅ 用户取消或未完成时不再显示红字报错,避免“像坏了”的体验 + let authError = error as? ASAuthorizationError + isAppleLoginLoading = false + if authError?.code == .canceled { + appleLoginError = nil + } else { + appleLoginError = "Apple 登录失败:\(error.localizedDescription)" + } + } + } + + func validateAgreement() -> Bool { + if !isAgreed { + withAnimation(Animation.default.repeatCount(3).speed(4)) { showShakeAnimation = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { showShakeAnimation = false } + return false + } + return true + } +} + +// MARK: - 手机号输入页 (API 接入) + +struct PhoneInputView: View { + @Binding var isLoggedIn: Bool + var onBack: () -> Void + var onClose: (() -> Void)? = nil // ✅ 新增:关闭回调(浮窗模式) + + @State private var phone = "" + @State private var code = "" + @State private var showCodeInput = false + @State private var isLoading = false + @State private var errorMessage: String? // 错误提示 + + var body: some View { + VStack(alignment: .leading, spacing: 32) { + // ✅ 核心修复 2:调整返回按钮样式与位置,使其与 X 按钮重合 + Button(action: onBack) { + Image(systemName: "arrow.left") + // 1. 字体调整:从 .title2 改为 size 20 medium,接近 X 按钮的 size 18 + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.inkPrimary) + } + // 2. 位置调整: + // X 按钮 top padding = 50, 内部 padding = 12, 总高度起点约为 62 + // 这里我们直接给 .top 62,抵消掉容器的边距影响 + .padding(.top, 50 + 12) + // 3. 左对齐微调: + .padding(.leading, 4) + + VStack(alignment: .leading, spacing: 8) { + Text(showCodeInput ? "输入验证码" : "手机号登录").font(.displayTitle()).foregroundColor(.inkPrimary) + Text(showCodeInput ? "验证码已发送至 \(phone)" : "未注册的手机号将自动创建账号").font(.bodyText()).foregroundColor(.inkSecondary) + } + + if let error = errorMessage { + Text(error).font(.caption).foregroundColor(.red) + } + + if !showCodeInput { + // Phone Input + HStack { Text("+86").fontWeight(.bold).foregroundColor(.inkPrimary); TextField("请输入手机号", text: $phone).keyboardType(.numberPad).font(.title3) }.padding().background(Color.white).cornerRadius(16) + + Button(action: sendCode) { + Group { + if isLoading { + ProgressView().tint(.white) + } else { + Text("获取验证码").font(.headline) + } + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + .contentShape(Rectangle()) + .background(phone.isEmpty ? Color.gray.opacity(0.3) : Color.brandVital) + .cornerRadius(28) + } + .buttonStyle(LoginPrimaryButtonStyle()) + .disabled(phone.isEmpty || isLoading) + + } else { + // Code Input + TextField("请输入验证码", text: $code).keyboardType(.numberPad).font(.title3).padding().background(Color.white).cornerRadius(16) + + Button(action: login) { + Group { + if isLoading { + ProgressView().tint(.white) + } else { + Text("开始成长").font(.headline) + } + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + .contentShape(Rectangle()) + .background(code.count < 4 ? Color.gray.opacity(0.3) : Color.brandVital) + .cornerRadius(28) + } + .buttonStyle(LoginPrimaryButtonStyle()) + .disabled(code.count < 4 || isLoading) + } + Spacer() + } + // ✅ 移除容器顶部的 padding,改由按钮自己控制,只保留横向和底部 + .padding(.horizontal, 32) + .padding(.bottom, 32) + } + + // API: 发送验证码 + func sendCode() { + isLoading = true + errorMessage = nil + Task { + do { + _ = try await AuthService.shared.sendCode(phone: phone) + await MainActor.run { + withAnimation { + isLoading = false + showCodeInput = true + } + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription + } + } + } + } + + // API: 登录 + func login() { + isLoading = true + errorMessage = nil + Task { + do { + let data = try await AuthService.shared.login(phone: phone, code: code) + await MainActor.run { + AnalyticsManager.shared.track("login_success", properties: ["method": "phone"]) + UserManager.shared.handleLoginSuccess(data) + isLoading = false + // ✅ 登录成功后,如果是在浮窗模式,自动关闭浮窗 + if let onClose = onClose { + print("📱 [Phone Login] 登录成功,自动关闭浮窗") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + onClose() + } + } + } + + // 【新增】静默拉取完整资料 + // 即使失败也不阻断登录流程,用户信息会在下次刷新时获取 + try? await UserManager.shared.fetchUserProfile() + } catch { + await MainActor.run { + AnalyticsManager.shared.track("login_fail", properties: ["method": "phone", "error": error.localizedDescription]) + isLoading = false + errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription + } + } + } + } +} + +// MARK: - 登录主按钮样式(整块可点 + 按压效果) + +private struct LoginPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) + } +} + +// MARK: - SafariView 组件(用于显示协议链接) + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> SFSafariViewController { + return SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { + } +} + +struct LoginView_Previews: PreviewProvider { static var previews: some View { LoginView(isLoggedIn: .constant(false)) } } diff --git a/ios/WildGrowth/WildGrowth/MainTabView.swift b/ios/WildGrowth/WildGrowth/MainTabView.swift new file mode 100644 index 0000000..684ac77 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/MainTabView.swift @@ -0,0 +1,216 @@ +import SwiftUI + +// MARK: - NavigationStore +class NavigationStore: ObservableObject { + @Published var homePath = NavigationPath() + @Published var growthPath = NavigationPath() + @Published var profilePath = NavigationPath() + + /// 当前选中的 Tab:0=发现,1=背包,2=我的 + @Published var selectedTab: Int = 0 + + var path: NavigationPath { + get { homePath } + set { homePath = newValue } + } + + /// 切换到背包 Tab 并清空栈(回到课程列表) + func switchToGrowthTab() { + selectedTab = 1 + growthPath = NavigationPath() + } +} + +// MARK: - 主视图容器 +struct MainTabView: View { + @Binding var isLoggedIn: Bool + @StateObject private var navStore = NavigationStore() + @State private var showLoginModal = false + // ✅ 移除 previousPathCount,因为现在有多个独立的 path + + // ✅ 完整还原:保留所有精细的 TabBar UI 配置 + init(isLoggedIn: Binding) { + _isLoggedIn = isLoggedIn + + let appearance = UITabBarAppearance() + appearance.configureWithTransparentBackground() // ✅ 透明背景 + appearance.backgroundColor = UIColor(Color.bgPaper) // ✅ 使用 bgPaper + appearance.shadowColor = nil + appearance.shadowImage = UIImage() + + let normalAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 16, weight: .medium), + .foregroundColor: UIColor(Color.inkSecondary) + ] + let selectedAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 18, weight: .bold), + .foregroundColor: UIColor(Color.inkPrimary) + ] + + let itemAppearance = UITabBarItemAppearance() + itemAppearance.normal.titleTextAttributes = normalAttrs + itemAppearance.selected.titleTextAttributes = selectedAttrs + + let textOffset = UIOffset(horizontal: 0, vertical: -16) + itemAppearance.normal.titlePositionAdjustment = textOffset + itemAppearance.selected.titlePositionAdjustment = textOffset + + appearance.stackedLayoutAppearance = itemAppearance + appearance.inlineLayoutAppearance = itemAppearance + + UITabBar.appearance().standardAppearance = appearance + // ✅ 找回版本检查 + if #available(iOS 15.0, *) { + UITabBar.appearance().scrollEdgeAppearance = appearance + } + } + + var body: some View { + ZStack { + // MARK: - Layer 1: 底层主视图 (TabView) + // 这一层永远保持"醒着",所以导航栏绝对不会坏 + TabView(selection: $navStore.selectedTab) { + // Tab 0: 发现 + NavigationStack(path: $navStore.homePath) { + DiscoveryView() + .toolbar(.hidden, for: .navigationBar) + } + .id("discover_nav_stack") + .environmentObject(navStore) + .tabItem { Text("发现") } + .tag(0) + + // Tab 1: 背包 + NavigationStack(path: $navStore.growthPath) { + GrowthView() + } + .id("growth_nav_stack") + .environmentObject(navStore) + .tabItem { Text("背包") } + .tag(1) + + // Tab 2: 我的 + NavigationStack(path: $navStore.profilePath) { + ProfileView(isLoggedIn: $isLoggedIn) + } + .id("profile_nav_stack") + .environmentObject(navStore) + .tabItem { Text("我的") } + .tag(2) + } + .accentColor(.inkPrimary) + + // MARK: - Layer 2: 登录浮窗 (Fake Popup) + if showLoginModal { + // A. 半透明黑色遮罩 (点击空白关闭) + Color.black.opacity(0.4) + .ignoresSafeArea() + .transition(.opacity) + .zIndex(100) + .onTapGesture { + print("🔍 [MainTabView] 点击遮罩,关闭登录浮窗") + closeLoginModal() + } + .onAppear { + print("🔍 [MainTabView] 登录浮窗遮罩 onAppear") + } + .onDisappear { + print("🔍 [MainTabView] 登录浮窗遮罩 onDisappear") + } + + // B. 登录页卡片 + GeometryReader { geo in + VStack { + Spacer() + + LoginView( + isLoggedIn: $isLoggedIn, + onClose: { + print("🔍 [MainTabView] LoginView onClose 回调") + closeLoginModal() + } + ) + .cornerRadius(24) + .frame(height: geo.size.height * 0.85) + .shadow(color: Color.black.opacity(0.2), radius: 20, x: 0, y: -5) + } + } + .transition(.move(edge: .bottom)) + .zIndex(101) + .edgesIgnoringSafeArea(.bottom) + .onAppear { + print("🔍 [MainTabView] 登录浮窗卡片 onAppear") + } + .onDisappear { + print("🔍 [MainTabView] 登录浮窗卡片 onDisappear") + } + } + + } + // 监听登录成功 -> 自动关闭浮窗并跳到"我的" + .onChange(of: isLoggedIn) { _, signedIn in + print("🔍 [MainTabView] isLoggedIn 变化:\(signedIn)") + if signedIn && showLoginModal { + // ✅ 登录成功后立即关闭浮窗,然后切换到"我的"Tab + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showLoginModal = false + } + // ✅ 延迟切换 Tab,确保浮窗关闭动画完成 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + navStore.selectedTab = 2 // ✅ 登录成功后跳转到"我的"Tab + } + } + } + .onChange(of: showLoginModal) { _, isShowing in + print("🔍 [MainTabView] showLoginModal 变化:\(isShowing)") + print(" 📊 [MainTabView] selection: \(navStore.selectedTab)") + print(" 📊 [MainTabView] isLoggedIn: \(isLoggedIn)") + print(" 📊 [MainTabView] 此时 TabView 的当前 Tab: \(navStore.selectedTab)") + print(" 📊 [MainTabView] 此时 navStore.homePath.count: \(navStore.homePath.count)") + + // 🔍 检查 NavigationStack 的 toolbar 状态 + // ✅ 已移除调试代码 + + if isShowing { + print(" ⚠️ [MainTabView] 浮窗显示 - 检查 NavigationStack 状态") + print(" 🔍 [MainTabView] 检查'发现'Tab 的 NavigationStack 是否受影响") + } else { + print(" ⚠️ [MainTabView] 浮窗关闭 - 检查 NavigationStack 状态") + print(" 🔍 [MainTabView] 检查'发现'Tab 的 NavigationStack 是否受影响") + // 浮窗关闭后,检查 NavigationStack 是否仍然存在 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + print(" 🔍 [MainTabView] 浮窗关闭后 0.1s,检查状态") + print(" 📊 [MainTabView] selection: \(navStore.selectedTab)") + print(" 📊 [MainTabView] showLoginModal: \(showLoginModal)") + print(" 📊 [MainTabView] navStore.path.count: \(navStore.path.count)") + // 🔍 再次检查 NavigationStack 的 toolbar 状态 + // ✅ 已移除调试代码 + } + } + } + // ✅ 移除统一的 path.count 监听,因为现在有多个独立的 path + // 如果需要,可以在各个 Tab 的 View 中单独监听对应的 path + } + + func closeLoginModal() { + print("🔍 [MainTabView] closeLoginModal() 调用") + print(" 📊 [MainTabView] showLoginModal 当前值: \(showLoginModal)") + print(" 📊 [MainTabView] selection 当前值: \(navStore.selectedTab)") + + // 🔍 关闭前检查 NavigationStack 的 toolbar 状态 + // ✅ 已移除调试代码 + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showLoginModal = false + } + print(" 📊 [MainTabView] showLoginModal 设置后值: false") + + // 🔍 关闭后检查 NavigationStack 的 toolbar 状态 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + // ✅ 已移除调试代码 + } + } + + // ✅ 已移除 checkNavigationBarState 调试函数 + +} diff --git a/ios/WildGrowth/WildGrowth/MapModels.swift b/ios/WildGrowth/WildGrowth/MapModels.swift new file mode 100644 index 0000000..d1d5dba --- /dev/null +++ b/ios/WildGrowth/WildGrowth/MapModels.swift @@ -0,0 +1,121 @@ +import Foundation + +// MARK: - API 响应结构 + +struct MapResponse: Codable { + let success: Bool + let data: MapData +} + +struct MapData: Codable, Equatable { + let courseId: String + let courseTitle: String + let courseSubtitle: String + let totalNodes: Int + let currentProgress: Int + var chapters: [MapChapter] + let themeColor: String? // ✅ 新增:主题色 Hex(如 "#2266FF") + let watermarkIcon: String? // ✅ 新增:水印图标名称(SF Symbol,如 "book.closed.fill") + let isJoined: Bool? // ✅ 新增:是否已加入 + + enum CodingKeys: String, CodingKey { + case courseId = "course_id" + case courseTitle = "course_title" + case courseSubtitle = "course_subtitle" + case totalNodes = "total_nodes" + case currentProgress = "current_progress" + case chapters + case themeColor = "theme_color" + case watermarkIcon = "watermark_icon" + case isJoined = "is_joined" + } +} + +struct MapChapter: Codable, Identifiable, Equatable { + let id: String + let title: String + let order: Int + var nodes: [MapNode] + + enum CodingKeys: String, CodingKey { + case id = "chapter_id" + case title, order, nodes + } + + // 火炬逻辑:只要有任意节点不是 locked,章节即激活 + var isActive: Bool { + return nodes.contains { $0.status != .locked } + } +} + +// 【修改】状态枚举:增加 notStarted +enum NodeStatus: String, Codable, Equatable { + case completed + case inProgress = "in_progress" + case notStarted = "not_started" // 【新增】解锁但未开始 + case locked +} + +struct MapNode: Codable, Identifiable, Equatable { + let id: String + let title: String + let order: Int + let duration: Int + var status: NodeStatus + var completionRate: Int? // 【新增】进度 0-100 + + enum CodingKeys: String, CodingKey { + case id, title, order, duration, status + case completionRate = "completion_rate" + } +} + +// MARK: - 升级后的 Mock 数据 (覆盖所有状态) + +extension MapData { + static let mock: MapData = MapData( + courseId: "c001", + courseTitle: "认知觉醒", + courseSubtitle: "开启你的元认知之旅", + totalNodes: 10, + currentProgress: 4, + chapters: [ + MapChapter( + id: "ch01", + title: "第一章:重新认识大脑", + order: 1, + nodes: [ + // 1. 已完成 (金, 100%) + MapNode(id: "n1", title: "我们为什么会痛苦?", order: 1, duration: 5, status: .completed, completionRate: 100), + // 2. 已完成 (金, 100%) + MapNode(id: "n2", title: "大脑的节能机制", order: 2, duration: 6, status: .completed, completionRate: 100) + ] + ), + MapChapter( + id: "ch02", + title: "第二章:潜意识的智慧", + order: 2, + nodes: [ + // 3. 进行中 (绿, 35%) + MapNode(id: "n3", title: "元认知能力 (上)", order: 3, duration: 5, status: .inProgress, completionRate: 35), + // 4. 已解锁但未开始 (实线灰, 0%) <-- 测试新状态 + MapNode(id: "n4", title: "元认知能力 (下)", order: 4, duration: 7, status: .notStarted, completionRate: 0) + ] + ), + MapChapter( + id: "ch03", + title: "第三章:元认知实战", + order: 3, + nodes: [ + // 5. 付费锁 (前端计算) + MapNode(id: "n5", title: "复盘与重构", order: 5, duration: 10, status: .locked, completionRate: 0), + // 6. 普通锁 (虚线灰) + MapNode(id: "n6", title: "情绪的物理按钮", order: 6, duration: 8, status: .locked, completionRate: 0) + ] + ) + ], + themeColor: "#2266FF", // ✅ Mock 数据:主题色 + watermarkIcon: "book.closed.fill", // ✅ Mock 数据:水印图标 + isJoined: false // ✅ Mock 数据:是否已加入 + ) +} diff --git a/ios/WildGrowth/WildGrowth/MapView.swift b/ios/WildGrowth/WildGrowth/MapView.swift new file mode 100644 index 0000000..edfe037 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/MapView.swift @@ -0,0 +1,641 @@ +import SwiftUI +import Kingfisher + +// MARK: - 1. 滚动检测器 +struct HeaderPositionKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +// MARK: - MapView 主视图 +struct MapView: View { + let courseId: String + + @EnvironmentObject private var navStore: NavigationStore + var navigationPath: Binding? + + private var currentPath: Binding { + navigationPath ?? $navStore.homePath + } + + init(courseId: String, navigationPath: Binding? = nil) { + self.courseId = courseId + self.navigationPath = navigationPath + } + + // 数据状态 + @State private var mapData: MapData? + @State private var activeNodeId: String = "" + @State private var isLoading = true + + // 视觉状态 + @State private var headerY: CGFloat = 1000 + + // 主题色逻辑:增加判空保护 + private var currentThemeColor: Color { + // ✅ 修复:增加 !hex.isEmpty 检查 + if let hex = mapData?.themeColor, !hex.isEmpty { + return Color(hex: hex) + } + return Color.brandVital + } + + // 业务状态 + @State private var isJoined: Bool = false + @State private var showActionSheet: Bool = false + @State private var showLoginSheet: Bool = false + @State private var showToast: Bool = false + @State private var toastMessage: String = "" + + @Environment(\.dismiss) var dismiss + + // 交互逻辑:导航栏透明度 + func getNavOpacity(safeAreaTop: CGFloat) -> Double { + let triggerPoint = safeAreaTop + 44 + 10 + let moveDistance = triggerPoint - headerY + if moveDistance > 10 { + return Double(min(moveDistance / 20, 1.0)) + } + return 0 + } + + // 交互逻辑:标题显示 + func shouldShowTitle(safeAreaTop: CGFloat) -> Bool { + return headerY < (safeAreaTop - 60) + } + + var body: some View { + GeometryReader { mainGeo in + let safeAreaTop = mainGeo.safeAreaInsets.top + + ZStack(alignment: .top) { + // 底色:浅灰背景 + Color.bgPaper.ignoresSafeArea() + + if isLoading { + ProgressView().tint(currentThemeColor).frame(maxHeight: .infinity) + } else if let data = mapData { + mapContentView(data: data, safeAreaTop: safeAreaTop) + } else { + // 错误占位 + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.inkSecondary) + Text("加载失败") + .font(.body) + .foregroundColor(.inkSecondary) + Button("重试") { loadMapData() } + .font(.buttonPrimary()) + .foregroundColor(currentThemeColor) + } + .frame(maxHeight: .infinity) + } + + // 导航栏 + if let data = mapData { + stickyNavBar( + title: data.courseTitle, + opacity: getNavOpacity(safeAreaTop: safeAreaTop), + showTitle: shouldShowTitle(safeAreaTop: safeAreaTop) + ) + .zIndex(10000) + .allowsHitTesting(true) + } + } + .coordinateSpace(name: "OuterSpace") + } + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .navigationBarBackButtonHidden(true) + .toast(message: toastMessage, isShowing: $showToast) + .confirmationDialog("管理课程", isPresented: $showActionSheet, titleVisibility: .hidden) { + Button("移出课程", role: .destructive) { handleRemoveCourse() } + Button("取消", role: .cancel) {} + } + .sheet(isPresented: $showLoginSheet) { + LoginView(isLoggedIn: Binding( + get: { UserManager.shared.isLoggedIn }, + set: { UserManager.shared.isLoggedIn = $0 } + ), onClose: { + showLoginSheet = false + checkJoinStatus() + }) + } + .onAppear { + AnalyticsManager.shared.track("course_map_view", properties: ["course_id": courseId]) + loadMapData() + checkJoinStatus() + } + .onChange(of: currentPath.wrappedValue.count) { newValue in + if newValue == 1 { + optimisticUpdateNodeStatus() + loadMapData() + } + } + .refreshable { + await refreshMapData() + } + } + + // MARK: - 主要内容布局 + @ViewBuilder + private func mapContentView(data: MapData, safeAreaTop: CGFloat) -> some View { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + // 1. 氛围横幅头部 + AtmosphericHeaderView(data: data, safeAreaTop: safeAreaTop) + .background( + GeometryReader { geo in + Color.clear.preference( + key: HeaderPositionKey.self, + value: geo.frame(in: .named("OuterSpace")).minY + ) + } + ) + .zIndex(1) // 确保头部在列表上方 + + // 2. 焦点流列表 (大白卡片容器) + VerticalListLayout( + data: data, + activeNodeId: activeNodeId, + courseId: courseId, + themeColor: currentThemeColor, + navigationPath: currentPath, + onTapNode: handleNodeTap + ) + .background(Color.bgWhite) // 大白卡片背景 + .cornerRadius(24, corners: [.topLeft, .topRight]) + .offset(y: -10) // 向上插入头部下方 + // ✅ 修复:移除了 .padding(.bottom, 20),让白色卡片扎实到底 + + Spacer().frame(height: 100) // 底部滚动留白 + } + } + .onPreferenceChange(HeaderPositionKey.self) { self.headerY = $0 } + .onChange(of: activeNodeId) { newNodeId in + if !newNodeId.isEmpty { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + withAnimation { + proxy.scrollTo(newNodeId, anchor: .center) + } + } + } + } + } + } + + // MARK: - 数据加载与逻辑 + + func loadMapData() { + isLoading = true + Task { + do { + let data = try await CourseService.shared.getCourseMap(courseId: courseId) + await MainActor.run { + self.mapData = data + + // 优先使用后端返回的 joined 状态 + if let backendJoined = data.isJoined { + self.isJoined = backendJoined + } + + if let firstActive = findFirstActiveNode(in: data) { + self.activeNodeId = firstActive + } + self.isLoading = false + } + } catch let apiError as APIError { + // ✅ 修复:恢复 unauthorized 处理 + await MainActor.run { + if case .unauthorized = apiError { + UserManager.shared.logout() + return + } + self.isLoading = false + print("❌ [MapView] Load Failed: \(apiError)") + } + } catch { + await MainActor.run { + self.isLoading = false + print("❌ [MapView] Load Failed: \(error)") + } + } + } + } + + func refreshMapData() async { + do { + let data = try await CourseService.shared.getCourseMap(courseId: courseId) + await MainActor.run { + self.mapData = data + if let backendJoined = data.isJoined { + self.isJoined = backendJoined + } + if let firstActive = findFirstActiveNode(in: data) { + self.activeNodeId = firstActive + } + } + } catch { + print("❌ [MapView] Refresh Failed: \(error)") + } + } + + func optimisticUpdateNodeStatus() { + guard var data = mapData else { return } + var hasUpdate = false + for chapterIndex in data.chapters.indices { + for nodeIndex in data.chapters[chapterIndex].nodes.indices { + let node = data.chapters[chapterIndex].nodes[nodeIndex] + if let rate = node.completionRate, rate >= 100, node.status != .completed { + data.chapters[chapterIndex].nodes[nodeIndex].status = .completed + hasUpdate = true + } + } + } + if hasUpdate { self.mapData = data } + } + + func findFirstActiveNode(in data: MapData) -> String? { + for chapter in data.chapters { + if let node = chapter.nodes.first(where: { $0.status == .inProgress }) { + return node.id + } + } + return nil + } + + func handleNodeTap(node: MapNode) { + if node.status == .locked { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.error) + } + } + + func checkJoinStatus() { + Task { + do { + let joined = try await CourseService.shared.checkIsJoined(courseId: courseId) + await MainActor.run { self.isJoined = joined } + } catch { + await MainActor.run { self.isJoined = false } + } + } + } + + func handleJoinButtonTap() { + guard TokenManager.shared.getToken() != nil else { + showLoginSheet = true + return + } + if isJoined { + showActionSheet = true + } else { + handleJoinCourse() + } + } + + func handleJoinCourse() { + Task { + do { + try await CourseService.shared.joinCourse(courseId: courseId) + await MainActor.run { + AnalyticsManager.shared.track("course_join", properties: ["course_id": courseId]) + self.isJoined = true + showToastMessage("已加入") + } + } catch { + await MainActor.run { showToastMessage("加入失败,请重试") } + } + } + } + + func handleRemoveCourse() { + Task { + do { + try await CourseService.shared.removeCourse(courseId: courseId) + await MainActor.run { + self.isJoined = false + showToastMessage("已移除") + } + } catch { + await MainActor.run { showToastMessage("移除失败,请重试") } + } + } + } + + func showToastMessage(_ message: String) { + toastMessage = message + showToast = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { showToast = false } + } + + // MARK: - 导航栏组件 + func stickyNavBar(title: String, opacity: Double, showTitle: Bool) -> some View { + HStack { + Button(action: { + if !currentPath.wrappedValue.isEmpty { + currentPath.wrappedValue.removeLast() + } else { + dismiss() + } + }) { + Image(systemName: "arrow.left") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(opacity > 0.5 ? .inkPrimary : .white) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + + if showTitle { + Text(title) + .font(.h3()) + .foregroundColor(.inkPrimary) + .lineLimit(1) + .transition(.opacity.animation(.easeInOut(duration: 0.2))) + } + + Spacer() + + // ✅ 移除导航栏右侧按钮:避免功能重复 + // 仅保留占位符以保持标题居中 + Spacer().frame(width: 44) + } + .frame(height: 44) + .background( + Color.bgWhite + .opacity(opacity) + .ignoresSafeArea(edges: .top) + ) + .frame(maxWidth: .infinity) + } + + /// 地图头部主题色(与发现页一致:theme_color 优先,否则 courseId 哈希从 6 色池选) + private func mapHeaderAccentColor(from data: MapData) -> Color { + if let hex = data.themeColor, !hex.isEmpty { + return Color(hex: hex) + } + var hash = 0 + for char in data.courseId.utf8 { + hash = (hash &* 31) &+ Int(char) + } + let colors: [Color] = [ + Color(hex: "2266FF"), Color(hex: "58CC02"), Color(hex: "8A4FFF"), + Color(hex: "FF4B4B"), Color(hex: "FFC800"), Color(hex: "FF9600") + ] + return colors[abs(hash) % colors.count] + } + + /// 地图头部背景渐变:与发现页一致(theme_color 优先,否则 courseId 哈希从 6 色池选) + private func mapHeaderGradient(from data: MapData) -> LinearGradient { + let color = mapHeaderAccentColor(from: data) + return LinearGradient( + gradient: Gradient(colors: [color, color.opacity(0.8)]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // MARK: - 头部组件:氛围横幅 + 悬浮操作卡片 + func AtmosphericHeaderView(data: MapData, safeAreaTop: CGFloat) -> some View { + let headerHeight: CGFloat = 280 + + return ZStack(alignment: .bottom) { + // 1. 背景(与发现页一致:theme_color 优先,否则 6 色池按 courseId 哈希) + mapHeaderGradient(from: data) + .ignoresSafeArea(edges: .top) + + // 2. 装饰性水印:✅ 修改位置,往下移一点,稍微变大一点 + if let iconName = data.watermarkIcon { + Image(systemName: iconName) + .font(.system(size: 220, weight: .bold)) // 放大 + .foregroundColor(.white.opacity(0.08)) + .position(x: 320, y: 180) // ✅ 坐标下移 (原 150) + .rotationEffect(.degrees(-15)) + } else { + Image(systemName: "book.closed.fill") + .font(.system(size: 220, weight: .bold)) + .foregroundColor(.white.opacity(0.08)) + .position(x: 320, y: 180) + .rotationEffect(.degrees(-15)) + } + + // 3. 标题内容层 + VStack(alignment: .leading, spacing: 12) { + Spacer() + + Text(data.courseTitle) + .font(.system(size: 32, weight: .heavy)) + .foregroundColor(.white) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 5) + // ✅ 修正:把标题往上顶一点 (padding bottom 加大) + .padding(.bottom, 4) + + Text(data.courseSubtitle) + .font(.body()) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(2) + .padding(.bottom, 70) // ✅ 留更多空间给悬浮卡片 + } + .padding(.horizontal, 24) + .padding(.top, safeAreaTop + 44) + .frame(maxWidth: .infinity, alignment: .leading) + + // 悬浮操作卡片 + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + // ✅ 优化:caption (11-12pt) → .label() (13pt) + Text("共 \(data.totalNodes) 节 · 已完成 \(data.currentProgress) 节") + .font(.label()) + .foregroundColor(.inkSecondary) + + // 进度条(与头部背景同一套取色) + let accentColor = mapHeaderAccentColor(from: data) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(accentColor.opacity(0.1)) + Capsule() + .fill(accentColor) + .frame(width: max(geo.size.width * CGFloat(data.currentProgress) / CGFloat(max(data.totalNodes, 1)), 0)) + } + } + .frame(height: 6) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // 右侧:加入/已加入按钮(与头部背景同一套取色) + Button(action: { + handleJoinButtonTap() + }) { + Text(isJoined ? "已加入" : "加入背包") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(isJoined ? .inkSecondary : .white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background( + isJoined ? + Color.inkSecondary.opacity(0.1) : + mapHeaderAccentColor(from: data) + ) + .cornerRadius(20) + } + } + .padding(20) + .background(Color.bgWhite) + .cornerRadius(16) + .shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 4) + .padding(.horizontal, 20) + // 悬浮偏移 + .offset(y: 20) + } + .frame(height: headerHeight) + .frame(maxWidth: .infinity) + .zIndex(1) + } +} + +// MARK: - 3. 布局组件 + +struct VerticalListLayout: View { + let data: MapData + let activeNodeId: String + let courseId: String + let themeColor: Color // ✅ 接收主题色 + var navigationPath: Binding + var onTapNode: (MapNode) -> Void + + var body: some View { + HStack { + Spacer() + LazyVStack(spacing: 0) { // 间距为0,由Row内部控制 + ForEach(data.chapters, id: \.id) { chapter in + Section { + ForEach(Array(chapter.nodes.enumerated()), id: \.element.id) { index, node in + let isLastNode = (chapter.id == data.chapters.last?.id) && (index == chapter.nodes.count - 1) + LessonListRow( + node: node, + isActive: node.id == activeNodeId, + isLast: index == chapter.nodes.count - 1, + themeColor: themeColor, + onTap: { + if [.completed, .inProgress, .notStarted].contains(node.status) { + navigationPath.wrappedValue.append(CourseNavigation.player( + courseId: courseId, + nodeId: node.id, + isLastNode: isLastNode, + courseTitle: data.courseTitle + )) + } else { + onTapNode(node) + } + } + ) + .id(node.id) + } + } header: { + HStack { + Text(chapter.title) + .font(.system(size: 22, weight: .bold)) // ✅ 优化:20 → 22 + .foregroundColor(.inkPrimary) + Spacer() + } + .padding(.top, 32) + .padding(.bottom, 12) + .padding(.horizontal, 24) + } + } + } + .frame(maxWidth: 700) + .padding(.top, 30) + .padding(.bottom, 60) + Spacer() + } + } +} + +// MARK: - 4. 单元组件 (Visual Noise Reduction Row) + +struct LessonListRow: View { + let node: MapNode + let isActive: Bool + let isLast: Bool + let themeColor: Color // ✅ 接收主题色 + var onTap: () -> Void + + var body: some View { + let isCompleted = node.status == .completed || (node.completionRate ?? 0) >= 100 + let isLocked = node.status == .locked + + Button(action: onTap) { + standardRowStyle(isCompleted: isCompleted, isLocked: isLocked) + } + .buttonStyle(ScaleButtonStyle()) + } + + // MARK: - 样式 B: 普通态 + func standardRowStyle(isCompleted: Bool, isLocked: Bool) -> some View { + VStack(spacing: 0) { + HStack(spacing: 16) { + Text(node.title) + .font(.system(size: 17, weight: .medium)) // ✅ 优化:16 → 17 + .foregroundColor(isCompleted ? .inkSecondary : .inkPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + // ✅ 状态小卡片 (Visual Noise Reduction) + if isLocked { + RoundedRectangle(cornerRadius: 3) + .stroke(style: StrokeStyle(lineWidth: 1.5, dash: [2])) + .foregroundColor(.inkSecondary.opacity(0.5)) + .frame(width: 18, height: 24) + .overlay( + Image(systemName: "lock.fill") + .font(.system(size: 10)) + .foregroundColor(.inkSecondary) + ) + } else if isCompleted { + // 已完成:浅灰边框 + 浅灰对勾 (视觉降噪) + RoundedRectangle(cornerRadius: 3) + .stroke(Color.inkSecondary.opacity(0.3), lineWidth: 1.5) // ✅ 浅灰边框 + .frame(width: 18, height: 24) + .overlay( + Image(systemName: "checkmark") // ✅ 浅灰对勾 + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.inkSecondary.opacity(0.5)) + ) + .rotationEffect(.degrees(6)) + } else { + // ✅ 修复:未开始:浅灰边框 + 灰色图标 (去色) + RoundedRectangle(cornerRadius: 3) + .stroke(Color.inkSecondary.opacity(0.3), lineWidth: 1.5) + .frame(width: 18, height: 24) + .overlay( + Image(systemName: "play.fill") + .font(.system(size: 9)) + .foregroundColor(.inkSecondary.opacity(0.8)) // 改为灰色 + ) + .rotationEffect(.degrees(6)) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 24) + + // ✅ 修复:分割线使用 padding 对齐,放弃手动 Spacer + if !isLast { + Rectangle() + .fill(Color.inkSecondary.opacity(0.1)) + .frame(height: 0.5) + .padding(.horizontal, 24) // 与内容 Padding 严格一致 + } + } + .contentShape(Rectangle()) + } +} diff --git a/ios/WildGrowth/WildGrowth/NoteBottomSheetView.swift b/ios/WildGrowth/WildGrowth/NoteBottomSheetView.swift new file mode 100644 index 0000000..6d11b9b --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NoteBottomSheetView.swift @@ -0,0 +1,228 @@ +import SwiftUI +import Kingfisher + +struct NoteBottomSheetView: View { + // MARK: - Data + let quotedText: String + let notes: [Note] + /// 当前用户头像 URL(用于笔记列表里「我的」笔记显示真实头像) + var currentUserAvatar: String? = nil + var currentUserId: String? = nil + /// 头像更新后递增,用于 URL 缓存破坏 + var avatarCacheBust: Int = 0 + + // MARK: - Callbacks + var onAddThought: () -> Void + var onDeleteHighlight: () -> Void + var onEditNote: (Note) -> Void + var onDeleteNote: (Note) -> Void + + // MARK: - Computed Properties + // ✅ 系统笔记功能:优先显示用户笔记,然后显示系统笔记 + private var thoughtNotes: [Note] { + let userNotes = notes.filter { $0.type == .thought && !$0.isSystemNote } + let systemNotes = notes.filter { $0.type == .thought && $0.isSystemNote } + // 用户笔记在前,系统笔记在后,各自按时间倒序 + return (userNotes.sorted { $0.createdAt > $1.createdAt } + + systemNotes.sorted { $0.createdAt > $1.createdAt }) + } + + private var hasHighlight: Bool { + notes.contains { $0.type == .highlight } + } + + // MARK: - Body + var body: some View { + VStack(spacing: 0) { + // 顶部抓手 + Capsule() + .fill(Color.inkSecondary.opacity(0.2)) + .frame(width: 40, height: 5) + .padding(.top, 10) + .padding(.bottom, 20) + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + + // 头部区域(紧凑布局) + VStack(alignment: .leading, spacing: 12) { + // 1. 原文卡片 + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "quote.opening") + .foregroundColor(.brandVital) + .font(.caption) + // ✅ 删除"原文"文字,只保留图标 + Spacer() + } + + Text(quotedText) + .font(.cardSubtitle) // ✅ 使用设计系统字体 (16pt medium) + .foregroundColor(.inkPrimary) + .lineLimit(4) + .lineSpacing(4) + .italic() // ✅ 新增:引用斜体,增加版式美感 + .padding(.leading, 12) + .overlay( + // ✅ 优化:装饰线透明度统一为 0.5 + Rectangle() + .fill(Color.brandVital.opacity(0.5)) + .frame(width: 3), + alignment: .leading + ) + } + .padding(12) // ✅ 紧凑:减小内边距(从 16 改为 12) + .background(Color.bgPaper) + .cornerRadius(12) + + // 2. 操作按钮行(紧凑布局) + HStack(spacing: 16) { + Button(action: onAddThought) { + Text("写想法") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.inkPrimary) // ✅ 弱化:改为深灰文字 + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color.inkSecondary.opacity(0.08)) // ✅ 弱化:改为极淡灰色背景 + .cornerRadius(16) + } + + Spacer() + + if hasHighlight { + Button(action: onDeleteHighlight) { + HStack { + Image(systemName: "trash") + Text("删除划线") + } + .font(.system(size: 13)) + .foregroundColor(.inkSecondary.opacity(0.6)) // ✅ 弱化:改为浅灰色文字 + } + } + } + .padding(.top, -4) // ✅ 紧凑:减小与引用区域的间距(从 -8 改为 -4,更自然) + + // 3. 分割线 + Divider() + .padding(.top, 4) // ✅ 紧凑:减小与按钮的间距(从 .vertical(8) 改为 .top(4)) + } + + // 3. 想法列表 + if thoughtNotes.isEmpty { + Text("暂无想法") + .font(.subheadline) + .foregroundColor(.inkSecondary.opacity(0.7)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 10) + } else { + LazyVStack(spacing: 24) { + ForEach(thoughtNotes) { note in + NoteItemCell( + note: note, + currentUserAvatar: currentUserAvatar, + currentUserId: currentUserId, + avatarCacheBust: avatarCacheBust, + onEdit: { onEditNote(note) }, + onDelete: { onDeleteNote(note) } + ) + } + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 40) + } + } + .background(Color.bgWhite) + .presentationDetents([.fraction(0.75), .large]) // ✅ 提高默认高度:从 65% 提升到 75% + .presentationDragIndicator(.visible) + // ✅ iPad 适配:使用 formSheet 样式,避免全屏显示(iOS 16.4+) + .modifier(iPadSheetAdaptationModifier()) + } +} + +struct NoteItemCell: View { + let note: Note + /// 当前用户头像 URL,用于「我的」笔记显示真实头像 + var currentUserAvatar: String? = nil + var currentUserId: String? = nil + var avatarCacheBust: Int = 0 + var onEdit: () -> Void + var onDelete: () -> Void + + // ✅ 系统笔记功能:判断是否为系统笔记 + private var isSystemNote: Bool { + return note.isSystemNote + } + + // ✅ 系统笔记功能:浮窗中系统笔记和用户笔记颜色一致(都是黑色) + private var textColor: Color { + Color.inkPrimary + } + + /// 是否显示当前用户真实头像(笔记属于当前用户且已上传头像) + private var showCurrentUserAvatar: Bool { + guard let uid = currentUserId, let avatar = currentUserAvatar, !avatar.isEmpty else { return false } + return note.userId == uid + } + + private var textAvatarCircle: some View { + Circle() + .fill(Color.gray.opacity(0.1)) + .frame(width: 36, height: 36) + .overlay( + Text(String((note.userName ?? "我").prefix(1))) + .font(.caption) + .foregroundColor(.inkSecondary) + ) + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Group { + if showCurrentUserAvatar, let baseURL = APIClient.shared.getImageURL(currentUserAvatar!) { + let urlWithBust = URL(string: baseURL.absoluteString + (baseURL.query != nil ? "&" : "?") + "t=\(avatarCacheBust)") + KFImage(urlWithBust ?? baseURL) + .resizable() + .placeholder { textAvatarCircle } + .aspectRatio(contentMode: .fill) + .frame(width: 36, height: 36) + .clipShape(Circle()) + } else { + textAvatarCircle + } + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(note.userName ?? "我") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(textColor) // ✅ 系统笔记使用深灰色 + + Spacer() + + // ✅ 系统笔记功能:系统笔记不可编辑、不可删除 + if !isSystemNote { + Menu { + Button("编辑", action: onEdit) + Button("删除", role: .destructive, action: onDelete) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 14)) + .foregroundColor(.inkSecondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + } + } + + Text(note.content ?? "") + .font(.system(size: 16)) + .foregroundColor(textColor) // ✅ 系统笔记使用深灰色 + .lineSpacing(4) + .padding(.top, 2) + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/NoteInputView.swift b/ios/WildGrowth/WildGrowth/NoteInputView.swift new file mode 100644 index 0000000..4cb762a --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NoteInputView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct NoteInputView: View { + // MARK: - Props + @Binding var isPresented: Bool + let selectedText: String + let initialContent: String // ✅ 支持传入初始内容 + let onSave: (String) -> Void + + // MARK: - State + @State private var inputText: String + @FocusState private var isInputFocused: Bool + + // ✅ 修正:使用默认参数,保持向后兼容 + init( + isPresented: Binding, + selectedText: String, + initialContent: String = "", + onSave: @escaping (String) -> Void + ) { + self._isPresented = isPresented + self.selectedText = selectedText + self.initialContent = initialContent + self.onSave = onSave + self._inputText = State(initialValue: initialContent) + } + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 20) { + + // 1. 引用卡片 + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "quote.opening") + .foregroundColor(.brandVital) + .font(.caption) + // ✅ 删除"原文引用"文字,只保留图标 + Spacer() + } + + Text(selectedText) + .font(.system(size: 15)) + .foregroundColor(.inkPrimary) + .lineLimit(3) // 最多显示3行 + .padding(.leading, 8) + .overlay( + Rectangle() + .fill(Color.brandVital.opacity(0.5)) + .frame(width: 3), + alignment: .leading + ) + } + .padding() + .background(Color.bgPaper) // 浅灰色背景 + .cornerRadius(12) + + // 2. 输入区域 + ZStack(alignment: .topLeading) { + // ✅ 优化背景层:Paper 质感 + 电光蓝微弱边框 + RoundedRectangle(cornerRadius: 12) + .fill(Color.bgPaper) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.brandVital.opacity(0.15), lineWidth: 1) + ) + + // 占位符 + if inputText.isEmpty { + Text("写下你的想法...") + .foregroundColor(.inkSecondary) // ✅ 使用纯色,符合设计系统 + .padding(.top, 20) // ✅ 保持:与 TextEditor padding 对齐 + .padding(.leading, 16) // ✅ 保持:与 TextEditor padding 对齐 + } + + // 输入框 + TextEditor(text: $inputText) + .focused($isInputFocused) + .scrollContentBackground(.hidden) // 隐藏默认白色背景 + .background(Color.clear) // 确保透明 + .frame(minHeight: 150) + .padding(12) // ✅ 内边距给足呼吸感 + } + + Spacer() + } + .padding(20) + .navigationTitle(initialContent.isEmpty ? "写想法" : "编辑想法") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + // 取消按钮 + ToolbarItem(placement: .cancellationAction) { + Button("取消") { + isPresented = false + } + .foregroundColor(.inkSecondary) + } + + // 发表按钮 + ToolbarItem(placement: .confirmationAction) { + Button("发表") { // ✅ 改为"发表" + handleSave() + } + .fontWeight(.semibold) + .foregroundColor(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .inkSecondary.opacity(0.5) : .brandVital) + .disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .presentationDetents([.medium, .large]) // 支持半屏和全屏 + .presentationDragIndicator(.visible) + // ✅ iPad 适配:使用 formSheet 样式,避免全屏显示(iOS 16.4+) + .modifier(iPadSheetAdaptationModifier()) + .onAppear { + // 延迟聚焦,提升体验 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isInputFocused = true + } + } + } + + private func handleSave() { + let content = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return } + + onSave(content) + isPresented = false + } +} + +// 预览 +struct NoteInputView_Previews: PreviewProvider { + static var previews: some View { + NoteInputView( + isPresented: .constant(true), + selectedText: "这是一个非常有深度的观点,值得反复阅读和思考。特别是关于AI时代人类学习重心的转移。", + initialContent: "", + onSave: { _ in } + ) + } +} diff --git a/ios/WildGrowth/WildGrowth/NoteListView.swift b/ios/WildGrowth/WildGrowth/NoteListView.swift new file mode 100644 index 0000000..17a550f --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NoteListView.swift @@ -0,0 +1,358 @@ +import SwiftUI + +// MARK: - 笔记导航目标(用于 NavigationPath) +enum NoteNavigationDestination: Hashable { + case player(courseId: String, nodeId: String, initialScrollIndex: Int?) +} + +// MARK: - 笔记列表主视图 +struct NoteListView: View { + @ObservedObject var userManager = UserManager.shared + @EnvironmentObject var navStore: NavigationStore + @State private var showLoginSheet: Bool = false + + var body: some View { + // ✅ 修正:移除 NavigationStack,使用父级的导航栈 + ZStack { + Color.bgPaper.ignoresSafeArea() + + if userManager.isLoggedIn { + // 已登录:显示笔记列表 + NoteListContent() + } else { + // 未登录:显示引导页 + NoteGuestView(onLoginTap: { + showLoginSheet = true + }) + } + } + .navigationTitle("笔记") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showLoginSheet) { + // ✅ 修正:使用正确的 LoginView 初始化 + LoginView( + isLoggedIn: Binding( + get: { UserManager.shared.isLoggedIn }, + set: { UserManager.shared.isLoggedIn = $0 } + ), + onClose: { + showLoginSheet = false + } + ) + } + // ✅ 修正:处理导航目标 + .navigationDestination(for: NoteNavigationDestination.self) { destination in + switch destination { + case .player(let courseId, let nodeId, let initialScrollIndex): + VerticalScreenPlayerView( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: initialScrollIndex + ) + .environmentObject(navStore) + } + } + } +} + +// MARK: - 1. 笔记列表主视图 (Task 3.2) + +struct NoteListContent: View { + @EnvironmentObject var navStore: NavigationStore + @State private var notes: [Note] = [] + @State private var isLoading: Bool = false + @State private var page: Int = 1 + @State private var hasMore: Bool = true + @State private var errorMessage: String? + + var body: some View { + Group { + if notes.isEmpty && !isLoading { + // ✅ 新增:空状态 UI + VStack(spacing: 16) { + Image(systemName: "note.text") + .font(.system(size: 48)) + .foregroundColor(.inkSecondary.opacity(0.5)) + Text("暂无笔记") + .font(.headline) + .foregroundColor(.inkPrimary) + Text("开始记录你的学习笔记吧") + .font(.caption) + .foregroundColor(.inkSecondary) + } + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(notes) { note in + // ✅ 修正:使用编程式导航 + NoteListCell(note: note) + .onTapGesture { + navigateToNote(note: note) + } + } + + // 底部加载更多指示器 + if hasMore { + ProgressView() + .padding() + .onAppear { + loadMoreNotes() + } + } else if !notes.isEmpty { + Text("— 已加载全部 —") + .font(.caption) + .foregroundColor(.inkSecondary.opacity(0.5)) + .padding() + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + } + .refreshable { + await refreshNotes() + } + } + } + .onAppear { + if notes.isEmpty { + loadFirstPage() + } + } + } + + // MARK: - Navigation Logic + + func navigateToNote(note: Note) { + // ✅ Phase 1 兼容性:处理可选字段 + guard let courseId = note.courseId, + let nodeId = note.nodeId else { + // 如果是独立笔记(没有 courseId/nodeId),无法导航到课程页面 + print("⚠️ [NoteListView] 无法导航:笔记缺少 courseId 或 nodeId") + return + } + + // ✅ 修正:使用 NavigationStore 进行导航 + // ✅ 使用 growthPath(NoteListView 在技能 Tab 的笔记流程中使用) + navStore.growthPath.append(NoteNavigationDestination.player( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: note.startIndex + )) + } + + // MARK: - Data Loading Logic + + func loadFirstPage() { + Task { + isLoading = true + do { + let result = try await NoteService.shared.getNotes(page: 1, limit: 20) + await MainActor.run { + self.notes = result.notes + self.page = 1 + self.hasMore = result.notes.count >= 20 + self.isLoading = false + self.errorMessage = nil + } + } catch { + await MainActor.run { + self.isLoading = false + self.errorMessage = "加载失败:\(error.localizedDescription)" + print("❌ [NoteList] 加载笔记失败: \(error)") + } + } + } + } + + func refreshNotes() async { + do { + let result = try await NoteService.shared.getNotes(page: 1, limit: 20) + await MainActor.run { + self.notes = result.notes + self.page = 1 + self.hasMore = result.notes.count >= 20 + self.errorMessage = nil + } + } catch { + await MainActor.run { + self.errorMessage = "刷新失败:\(error.localizedDescription)" + print("❌ [NoteList] 刷新笔记失败: \(error)") + } + } + } + + func loadMoreNotes() { + guard !isLoading && hasMore else { return } + Task { + isLoading = true + do { + let result = try await NoteService.shared.getNotes(page: page + 1, limit: 20) + await MainActor.run { + self.notes.append(contentsOf: result.notes) + self.page += 1 + self.hasMore = result.notes.count >= 20 + self.isLoading = false + self.errorMessage = nil + } + } catch { + await MainActor.run { + self.isLoading = false + self.errorMessage = "加载更多失败:\(error.localizedDescription)" + print("❌ [NoteList] 加载更多笔记失败: \(error)") + } + } + } + } +} + +// MARK: - 2. 笔记单元格组件 (Task 3.2 Cell) + +struct NoteListCell: View { + let note: Note + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + + // 头部:课程信息/来源 + HStack { + Image(systemName: "book.closed.fill") + .font(.caption) + .foregroundColor(.brandVital) + Text(note.nodeTitle ?? note.courseTitle ?? "未知课程") + .font(.caption) + .foregroundColor(.inkSecondary) + .lineLimit(1) + Spacer() + Text(formatDate(note.createdAt)) + .font(.caption2) + .foregroundColor(.inkSecondary.opacity(0.7)) + } + + if note.type == .thought { + // === Type A: 想法 === + VStack(alignment: .leading, spacing: 8) { + // 想法内容 + Text(note.content ?? "") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.inkPrimary) + .lineSpacing(4) + .lineLimit(3) + + // 引用原文 + HStack(alignment: .top) { + Rectangle() + .fill(Color.inkSecondary.opacity(0.3)) + .frame(width: 2) + + Text(note.quotedText ?? "") + .font(.system(size: 14)) + .foregroundColor(.inkSecondary) + .lineLimit(2) + .padding(.leading, 4) + } + } + } else { + // === Type B: 纯划线 === + HStack(alignment: .top, spacing: 12) { + Image(systemName: "highlighter") + .font(.system(size: 14)) + .foregroundColor(.brandVital) + .padding(.top, 4) + + Text(note.quotedText ?? "") + .font(.system(size: 16)) + .foregroundColor(.inkPrimary) + .padding(.horizontal, 4) + .background(Color.brandVital.opacity(0.2)) // 高亮背景 + .lineLimit(4) + .lineSpacing(4) + } + } + } + .padding(16) + .background(Color.bgWhite) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + } + + // ✅ 修正:使用更友好的日期格式 + func formatDate(_ date: Date) -> String { + let calendar = Calendar.current + if calendar.isDateInToday(date) { + let formatter = DateFormatter() + formatter.dateFormat = "今天 HH:mm" + return formatter.string(from: date) + } else if calendar.isDateInYesterday(date) { + let formatter = DateFormatter() + formatter.dateFormat = "昨天 HH:mm" + return formatter.string(from: date) + } else { + let formatter = DateFormatter() + formatter.dateFormat = "MM-dd HH:mm" + return formatter.string(from: date) + } + } +} + +// MARK: - 3. 未登录引导页 (Task 3.3) + +struct NoteGuestView: View { + var onLoginTap: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer() + + // 插图 + Image(systemName: "note.text.badge.plus") + .font(.system(size: 60)) + .foregroundColor(.brandVital.opacity(0.5)) + .padding() + .background( + Circle() + .fill(Color.brandVital.opacity(0.1)) + .frame(width: 120, height: 120) + ) + + // 文案 + VStack(spacing: 8) { + Text("开启知识笔记") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.inkPrimary) + + Text("登录后可使用笔记功能\n构建属于你自己的知识体系") + .font(.subheadline) + .foregroundColor(.inkSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + + // 按钮 + Button(action: onLoginTap) { + Text("立即登录") + .font(.headline) + .foregroundColor(.white) + .frame(width: 200, height: 50) + .background(Color.brandVital) + .cornerRadius(25) + .shadow(color: Color.brandVital.opacity(0.3), radius: 10, x: 0, y: 5) + } + .padding(.top, 16) + + Spacer() + Spacer() // 稍微偏上一点 + } + .padding() + } +} + +// MARK: - 预览 +struct NoteListView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + NoteListView() + .environmentObject(NavigationStore()) + } + } +} diff --git a/ios/WildGrowth/WildGrowth/NoteModels.swift b/ios/WildGrowth/WildGrowth/NoteModels.swift new file mode 100644 index 0000000..7f68283 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NoteModels.swift @@ -0,0 +1,335 @@ +import Foundation + +// MARK: - 笔记类型枚举 +enum NoteType: String, Codable { + case highlight = "highlight" + case thought = "thought" + case comment = "comment" // 未来扩展 +} + +// MARK: - 系统用户ID常量 +extension Note { + /// 系统用户ID(用于系统笔记) + static let systemUserId = "00000000-0000-0000-0000-000000000000" + + /// 判断是否为系统笔记 + var isSystemNote: Bool { + return userId == Note.systemUserId + } +} + +// MARK: - ✅ Phase 1: 新增 Notebook 模型 +struct Notebook: Identifiable, Codable, Hashable { + let id: String + let userId: String + let title: String + let description: String? + let coverImage: String? + let noteCount: Int? // 计算字段,可选 + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId = "user_id" + case title, description + case coverImage = "cover_image" + case noteCount = "note_count" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + // 自定义解码,处理日期格式 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + userId = try container.decode(String.self, forKey: .userId) + title = try container.decode(String.self, forKey: .title) + description = try container.decodeIfPresent(String.self, forKey: .description) + coverImage = try container.decodeIfPresent(String.self, forKey: .coverImage) + noteCount = try container.decodeIfPresent(Int.self, forKey: .noteCount) + + // 解析日期 + let createdAtString = try container.decode(String.self, forKey: .createdAt) + guard let createdAtDate = ISO8601DateFormatters.date(from: createdAtString) else { + throw DecodingError.dataCorruptedError( + forKey: .createdAt, + in: container, + debugDescription: "Invalid date format: \(createdAtString)" + ) + } + createdAt = createdAtDate + + let updatedAtString = try container.decode(String.self, forKey: .updatedAt) + guard let updatedAtDate = ISO8601DateFormatters.date(from: updatedAtString) else { + throw DecodingError.dataCorruptedError( + forKey: .updatedAt, + in: container, + debugDescription: "Invalid date format: \(updatedAtString)" + ) + } + updatedAt = updatedAtDate + } +} + +// MARK: - ✅ Phase 1: 扩展 Note 模型,支持笔记本和层级结构 +struct Note: Identifiable, Codable { + let id: String + let userId: String + + // ✅ 新增:笔记本和层级字段 + let notebookId: String? // 所属笔记本 ID,nil 表示未分类 + let parentId: String? // 父笔记 ID,nil 表示顶级 + let order: Int // 同级排序,0, 1, 2... + let level: Int // 层级深度,0=顶级, 1=二级, 2=三级 + + // ✅ 修改:这些字段改为可选(支持独立笔记) + let courseId: String? + let nodeId: String? + let startIndex: Int? // 全局索引(独立笔记为 null) + let length: Int? // 全局索引(独立笔记为 null) + + let type: NoteType + let content: String? // 可选(想法笔记有内容,划线笔记可能为空) + let quotedText: String? // ✅ 修改:改为可选,独立笔记为 null + + // ✅ 保留可选字段(用于列表展示,不影响核心功能) + let userName: String? + let courseTitle: String? + let nodeTitle: String? + let style: String? + + // ✅ 日期保持为 Date 类型(与现有代码兼容) + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId = "user_id" + case userName = "user_name" + // ✅ 新增字段 + case notebookId = "notebook_id" + case parentId = "parent_id" + case order, level + // ✅ 修改为可选 + case courseId = "course_id" + case nodeId = "node_id" + case startIndex = "start_index" + case length, type, content + case quotedText = "quoted_text" + case style + case createdAt = "created_at" + case updatedAt = "updated_at" + case courseTitle = "course_title" + case nodeTitle = "node_title" + } + + // 自定义解码,处理日期格式 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + userId = try container.decode(String.self, forKey: .userId) + userName = try container.decodeIfPresent(String.self, forKey: .userName) + + // ✅ 新增字段:笔记本和层级 + notebookId = try container.decodeIfPresent(String.self, forKey: .notebookId) + parentId = try container.decodeIfPresent(String.self, forKey: .parentId) + order = try container.decodeIfPresent(Int.self, forKey: .order) ?? 0 + level = try container.decodeIfPresent(Int.self, forKey: .level) ?? 0 + + // ✅ 修改为可选:支持独立笔记 + courseId = try container.decodeIfPresent(String.self, forKey: .courseId) + nodeId = try container.decodeIfPresent(String.self, forKey: .nodeId) + startIndex = try container.decodeIfPresent(Int.self, forKey: .startIndex) + length = try container.decodeIfPresent(Int.self, forKey: .length) + quotedText = try container.decodeIfPresent(String.self, forKey: .quotedText) + + type = try container.decode(NoteType.self, forKey: .type) + content = try container.decodeIfPresent(String.self, forKey: .content) + style = try container.decodeIfPresent(String.self, forKey: .style) + courseTitle = try container.decodeIfPresent(String.self, forKey: .courseTitle) + nodeTitle = try container.decodeIfPresent(String.self, forKey: .nodeTitle) + + // 解析日期 + let createdAtString = try container.decode(String.self, forKey: .createdAt) + guard let createdAtDate = ISO8601DateFormatters.date(from: createdAtString) else { + throw DecodingError.dataCorruptedError( + forKey: .createdAt, + in: container, + debugDescription: "Invalid date format: \(createdAtString)" + ) + } + createdAt = createdAtDate + + let updatedAtString = try container.decode(String.self, forKey: .updatedAt) + guard let updatedAtDate = ISO8601DateFormatters.date(from: updatedAtString) else { + throw DecodingError.dataCorruptedError( + forKey: .updatedAt, + in: container, + debugDescription: "Invalid date format: \(updatedAtString)" + ) + } + updatedAt = updatedAtDate + } + + // MARK: - 便利初始化器(用于创建临时 Note) + init( + id: String, + userId: String, + userName: String?, + notebookId: String? = nil, + parentId: String? = nil, + order: Int = 0, + level: Int = 0, + courseId: String? = nil, + nodeId: String? = nil, + startIndex: Int? = nil, + length: Int? = nil, + type: NoteType, + content: String?, + quotedText: String? = nil, + style: String? = nil, + createdAt: Date, + updatedAt: Date, + courseTitle: String? = nil, + nodeTitle: String? = nil + ) { + self.id = id + self.userId = userId + self.userName = userName + self.notebookId = notebookId + self.parentId = parentId + self.order = order + self.level = level + self.courseId = courseId + self.nodeId = nodeId + self.startIndex = startIndex + self.length = length + self.type = type + self.content = content + self.quotedText = quotedText + self.style = style + self.createdAt = createdAt + self.updatedAt = updatedAt + self.courseTitle = courseTitle + self.nodeTitle = nodeTitle + } +} + +// MARK: - ✅ Phase 1: Notebook API 响应模型 +struct NotebookListResponse: Codable { + let success: Bool + let data: NotebookListData +} + +struct NotebookListData: Codable { + let notebooks: [Notebook] + let total: Int +} + +struct NotebookResponse: Codable { + let success: Bool + let data: NotebookData +} + +struct NotebookData: Codable { + let notebook: Notebook +} + +// MARK: - API 响应模型 +struct NoteListResponse: Codable { + let success: Bool + let data: NoteListData +} + +struct NoteListData: Codable { + let notes: [Note] + let total: Int +} + +struct NoteResponse: Codable { + let success: Bool + let data: Note +} + +// MARK: - ✅ Phase 1: 创建笔记本请求体 +struct CreateNotebookRequest: Codable { + let title: String + let description: String? + let coverImage: String? + + enum CodingKeys: String, CodingKey { + case title, description + case coverImage = "cover_image" + } +} + +// MARK: - ✅ Phase 1: 更新笔记本请求体 +struct UpdateNotebookRequest: Codable { + let title: String? + let description: String? + let coverImage: String? + + enum CodingKeys: String, CodingKey { + case title, description + case coverImage = "cover_image" + } +} + +// MARK: - 创建笔记请求体(✅ Phase 1: 扩展支持笔记本和层级) +struct CreateNoteRequest: Codable { + // ✅ 新增:笔记本和层级字段 + let notebookId: String? + let parentId: String? + let order: Int? + let level: Int? + + // ✅ 修改为可选:支持独立笔记 + let courseId: String? + let nodeId: String? + let startIndex: Int? // 可选:全局 NSRange.location + let length: Int? // 可选:全局 NSRange.length + + let type: String // "highlight" or "thought" + let quotedText: String? // ✅ 修改为可选:独立笔记为 null + let content: String? // 可选(想法笔记有内容,划线笔记可能为空) + let style: String? // 可选 + + enum CodingKeys: String, CodingKey { + case notebookId = "notebook_id" + case parentId = "parent_id" + case order, level + case courseId = "course_id" + case nodeId = "node_id" + case startIndex = "start_index" + case length, type + case quotedText = "quoted_text" + case content, style + } +} + +// MARK: - ✅ Phase 1: 更新笔记请求体(扩展支持层级调整) +struct UpdateNoteRequest: Codable { + // ✅ 新增:层级调整字段 + let parentId: String? + let order: Int? + let level: Int? + + // 现有字段 + let content: String? + let quotedText: String? + let startIndex: Int? + let length: Int? + let style: String? + + enum CodingKeys: String, CodingKey { + case parentId = "parent_id" + case order, level + case content + case quotedText = "quoted_text" + case startIndex = "start_index" + case length, style + } +} diff --git a/ios/WildGrowth/WildGrowth/NoteService.swift b/ios/WildGrowth/WildGrowth/NoteService.swift new file mode 100644 index 0000000..a1e1799 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NoteService.swift @@ -0,0 +1,210 @@ +import Foundation + +class NoteService { + static let shared = NoteService() + private let apiClient = APIClient.shared + + private init() {} + + // MARK: - 创建笔记 + func createNote(_ request: CreateNoteRequest) async throws -> Note { + // 将 Codable 结构体转换为字典 + let encoder = JSONEncoder() + guard let jsonData = try? encoder.encode(request), + let bodyDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw APIError.serverError("请求体编码失败") + } + + let response: NoteResponse = try await apiClient.request( + endpoint: "/api/notes", + method: "POST", + body: bodyDict, + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("创建笔记失败") + } + + return response.data + } + + // MARK: - 获取笔记列表 + func getNotes( + courseId: String? = nil, + nodeId: String? = nil, + notebookId: String? = nil, // ✅ Phase 3: 新增按笔记本过滤 + type: NoteType? = nil, + page: Int = 1, + limit: Int = 20 + ) async throws -> (notes: [Note], total: Int) { + // 构建查询参数 + var queryComponents: [String] = [] + if let courseId = courseId { + queryComponents.append("course_id=\(courseId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? courseId)") + } + if let nodeId = nodeId { + queryComponents.append("node_id=\(nodeId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? nodeId)") + } + // ✅ Phase 3: 新增按笔记本过滤 + if let notebookId = notebookId { + queryComponents.append("notebook_id=\(notebookId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? notebookId)") + } + if let type = type { + queryComponents.append("type=\(type.rawValue)") + } + queryComponents.append("page=\(page)") + queryComponents.append("limit=\(limit)") + + let queryString = queryComponents.isEmpty ? "" : "?\(queryComponents.joined(separator: "&"))" + + let response: NoteListResponse = try await apiClient.request( + endpoint: "/api/notes\(queryString)", + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取笔记列表失败") + } + + return (response.data.notes, response.data.total) + } + + // MARK: - ✅ Phase 3: 获取笔记本下的所有笔记 + func getNotesByNotebook(notebookId: String, page: Int = 1, limit: Int = 50) async throws -> (notes: [Note], total: Int) { + return try await getNotes(notebookId: notebookId, page: page, limit: limit) + } + + // MARK: - ✅ Phase 3: 更新笔记层级和排序 + func updateNoteHierarchy(noteId: String, parentId: String?, order: Int?, level: Int?) async throws -> Note { + let request = UpdateNoteRequest( + parentId: parentId, + order: order, + level: level, + content: nil, + quotedText: nil, + startIndex: nil, + length: nil, + style: nil + ) + + return try await updateNote(noteId: noteId, request: request) + } + + // MARK: - ✅ Phase 3: 移动笔记(调整层级) + enum MoveDirection { + case up // 上移(同级调整顺序) + case down // 下移(同级调整顺序) + case indent // 缩进(降级) + case outdent // 升级 + } + + func moveNote(noteId: String, direction: MoveDirection) async throws -> Note { + // 先获取当前笔记 + let currentNote = try await getNote(noteId: noteId) + + // 根据方向计算新的层级和排序 + var newParentId: String? = currentNote.parentId + var newOrder: Int = currentNote.order + var newLevel: Int = currentNote.level + + switch direction { + case .up: + // 上移:order - 1(需要与同级前一个笔记交换) + newOrder = max(0, currentNote.order - 1) + + case .down: + // 下移:order + 1(需要与同级后一个笔记交换) + newOrder = currentNote.order + 1 + + case .indent: + // 缩进(降级):成为前一个笔记的子节点 + // 需要找到前一个同级笔记 + // 这里简化处理,实际需要查询前一个笔记 + if currentNote.level < 2 { + newLevel = currentNote.level + 1 + // TODO: 实现查找前一个笔记的逻辑 + } + + case .outdent: + // 升级:成为父节点的兄弟节点 + if let parentId = currentNote.parentId { + // 获取父笔记 + let parentNote = try await getNote(noteId: parentId) + newParentId = parentNote.parentId + newLevel = max(0, currentNote.level - 1) + // TODO: 实现计算新 order 的逻辑 + } + } + + return try await updateNoteHierarchy( + noteId: noteId, + parentId: newParentId, + order: newOrder, + level: newLevel + ) + } + + // MARK: - 获取单个笔记 + func getNote(noteId: String) async throws -> Note { + let response: NoteResponse = try await apiClient.request( + endpoint: "/api/notes/\(noteId)", + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取笔记失败") + } + + return response.data + } + + // MARK: - 获取节点下的所有笔记(用于在课程内容中显示) + func getNotesForNode(nodeId: String) async throws -> [Note] { + let response: NoteListResponse = try await apiClient.request( + endpoint: "/api/lessons/\(nodeId)/notes", + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取节点笔记失败") + } + + return response.data.notes + } + + // MARK: - 更新笔记 + func updateNote(noteId: String, request: UpdateNoteRequest) async throws -> Note { + // 将 Codable 结构体转换为字典 + let encoder = JSONEncoder() + guard let jsonData = try? encoder.encode(request), + let bodyDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw APIError.serverError("请求体编码失败") + } + + let response: NoteResponse = try await apiClient.request( + endpoint: "/api/notes/\(noteId)", + method: "PUT", + body: bodyDict, + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("更新笔记失败") + } + + return response.data + } + + // MARK: - 删除笔记 + func deleteNote(noteId: String) async throws { + let _: EmptyResponse = try await apiClient.request( + endpoint: "/api/notes/\(noteId)", + method: "DELETE", + requiresAuth: true + ) + } +} diff --git a/ios/WildGrowth/WildGrowth/NoteTreeView.swift b/ios/WildGrowth/WildGrowth/NoteTreeView.swift new file mode 100644 index 0000000..d0305c1 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NoteTreeView.swift @@ -0,0 +1,327 @@ +import SwiftUI + +struct NoteTreeView: View { + let notebook: Notebook + + // 数据状态 + @State private var notes: [Note] = [] + @State private var isLoading = true + + // 交互状态 + @State private var showToast = false + @State private var toastMessage = "" + // ❌ MVP 版本:移除新增笔记入口 + // @State private var showCreateSheet = false + + // 编辑状态 + @State private var editingNote: Note? = nil + + // ✅ 导航支持 + @EnvironmentObject var navStore: NavigationStore + + var body: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 0) { + if isLoading && notes.isEmpty { + Spacer() + ProgressView() + Spacer() + } else if notes.isEmpty { + emptyStateView + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(notes) { note in + NoteTreeRow( + note: note, + // ✅ 点击回调:跳转原文 + onJumpToOriginal: { + jumpToOriginal(note) + }, + // ✅ 删除回调 + onDelete: { + deleteNote(note) + }, + // ✅ 编辑回调 + onEdit: { + editingNote = note + } + ) + .id(note.id) + } + } + .padding(.top, 16) + .padding(.bottom, 100) + } + .scrollDismissesKeyboard(.interactively) + } + } + } + + // ❌ MVP 版本:移除新增笔记入口 + // VStack { + // Spacer() + // floatingAddButton + // .transition(.scale.combined(with: .opacity)) + // } + } + .navigationTitle(notebook.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .tabBar) // ✅ 隐藏底部TabBar + .onAppear { + // ✅ 强制隐藏 TabBar(使用 UIKit 方式确保生效) + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let tabBarController = window.rootViewController as? UITabBarController { + tabBarController.tabBar.isHidden = true + } + } + .onDisappear { + // ✅ 恢复 TabBar(离开页面时) + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let tabBarController = window.rootViewController as? UITabBarController { + tabBarController.tabBar.isHidden = false + } + } + // ❌ MVP 版本:移除脑图按钮 + // .toolbar { + // ToolbarItem(placement: .topBarTrailing) { + // Button { + // // Phase 6: 脑图 + // } label: { + // Image(systemName: "arrow.triangle.branch") + // .font(.system(size: 14)) + // .foregroundColor(.brandVital) + // } + // } + // } + .onAppear { + loadData() + } + .toast(message: toastMessage, isShowing: $showToast) + // ❌ MVP 版本:移除新增笔记入口 + // .sheet(isPresented: $showCreateSheet) { + // Text("新建笔记界面 (Phase 5.4)") + // .modifier(iPadSheetAdaptationModifier()) + // } + .sheet(item: $editingNote) { note in + SimpleEditNoteView(note: note) { newContent in + updateNoteContent(note, content: newContent) + } + .modifier(iPadSheetAdaptationModifier()) + } + // ✅ 导航目标处理 + .navigationDestination(for: NoteNavigationDestination.self) { destination in + switch destination { + case .player(let courseId, let nodeId, let initialScrollIndex): + VerticalScreenPlayerView( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: initialScrollIndex + ) + .environmentObject(navStore) + } + } + } + + // MARK: - Subviews + + private var emptyStateView: some View { + VStack(spacing: 20) { + Spacer() + Image(systemName: "square.and.pencil") + .font(.system(size: 56)) + .foregroundColor(.inkSecondary.opacity(0.2)) + Text("笔记本是空的") + .font(.title3) + .foregroundColor(.inkSecondary) + // ❌ MVP 版本:移除新增笔记入口 + // Button { showCreateSheet = true } label: { + // Text("写下第一条想法") + // .fontWeight(.bold) + // .foregroundColor(.white) + // .padding(.horizontal, 24) + // .padding(.vertical, 12) + // .background(Color.brandVital) + // .cornerRadius(20) + // } + Spacer() + } + } + + // ❌ MVP 版本:移除新增笔记入口 + // private var floatingAddButton: some View { + // HStack { + // Spacer() + // Button { showCreateSheet = true } label: { + // Image(systemName: "plus") + // .font(.system(size: 24, weight: .semibold)) + // .foregroundColor(.white) + // .frame(width: 56, height: 56) + // .background( + // Circle() + // .fill(Color.brandVital) + // .shadow(color: .brandVital.opacity(0.4), radius: 8, y: 4) + // ) + // } + // .padding(.trailing, 24) + // .padding(.bottom, 30) + // } + // } + + // MARK: - Actions + + private func loadData() { + Task { + do { + let (items, _) = try await NoteService.shared.getNotesByNotebook(notebookId: notebook.id) + await MainActor.run { + // ✅ 系统笔记功能:过滤掉系统笔记(系统笔记在笔记本中不可见) + self.notes = items.filter { !$0.isSystemNote } + self.isLoading = false + } + } catch { + await MainActor.run { + self.isLoading = false + self.toastMessage = "加载失败: \(error.localizedDescription)" + self.showToast = true + } + } + } + } + + private func deleteNote(_ note: Note) { + Task { + do { + try await NoteService.shared.deleteNote(noteId: note.id) + await MainActor.run { + self.notes.removeAll { $0.id == note.id } + self.toastMessage = "已删除" + self.showToast = true + // ✅ 2秒后自动消失 + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.showToast = false + } + } + } catch { + await MainActor.run { + self.toastMessage = "删除失败: \(error.localizedDescription)" + self.showToast = true + // ✅ 2秒后自动消失 + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.showToast = false + } + } + } + } + } + + private func updateNoteContent(_ note: Note, content: String) { + Task { + do { + let request = UpdateNoteRequest( + parentId: note.parentId, + order: note.order, + level: note.level, + content: content, + quotedText: note.quotedText, + startIndex: note.startIndex, + length: note.length, + style: note.style + ) + _ = try await NoteService.shared.updateNote(noteId: note.id, request: request) + await MainActor.run { + editingNote = nil + // ✅ 不显示Toast,用户可以直接看到编辑结果 + loadData() + } + } catch { + await MainActor.run { + self.toastMessage = "保存失败: \(error.localizedDescription)" + self.showToast = true + } + } + } + } + + // ✅ 跳转逻辑:跳转到原文 + private func jumpToOriginal(_ note: Note) { + guard let courseId = note.courseId, + let nodeId = note.nodeId else { + toastMessage = "无法跳转:笔记缺少必要信息" + showToast = true + return + } + + // 使用 NavigationPath 跳转到 VerticalScreenPlayerView + let initialScrollIndex = note.startIndex + + // ✅ 修复:智能判断应该使用哪个导航路径 + // 比较 profilePath 和 growthPath 的深度,选择更深的路径(更可能是当前使用的路径) + // 如果两者深度相同,优先使用 profilePath(因为现在笔记主要从 ProfileView 进入) + if navStore.profilePath.count >= navStore.growthPath.count { + navStore.profilePath.append(NoteNavigationDestination.player( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: initialScrollIndex + )) + } else { + navStore.growthPath.append(NoteNavigationDestination.player( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: initialScrollIndex + )) + } + } +} + +// 辅助组件:SimpleEditNoteView +struct SimpleEditNoteView: View { + let note: Note + let onSave: (String) -> Void + + @State private var content: String + @Environment(\.dismiss) var dismiss + + init(note: Note, onSave: @escaping (String) -> Void) { + self.note = note + self.onSave = onSave + _content = State(initialValue: note.content ?? "") + } + + var body: some View { + NavigationStack { + Form { + if let quote = note.quotedText { + Section(header: Text("引用原文")) { + Text(quote) + .foregroundColor(.secondary) + .font(.footnote) + } + } + + Section(header: Text("我的想法")) { + TextField("写下你的想法...", text: $content, axis: .vertical) + .lineLimit(5...10) + } + } + .navigationTitle("编辑笔记") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("发表") { // ✅ 改为"发表" + onSave(content) + } + .disabled(content.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/NotebookListView.swift b/ios/WildGrowth/WildGrowth/NotebookListView.swift new file mode 100644 index 0000000..f3d03af --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NotebookListView.swift @@ -0,0 +1,293 @@ +import SwiftUI + +struct NotebookListView: View { + // ✅ 修正:使用项目实际的 UserManager + @ObservedObject var userManager = UserManager.shared + + // 环境变量:用于 iPad 适配判断 + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + // 数据状态 + @State private var notebooks: [Notebook] = [] + @State private var isLoading = true + + // 交互状态 (❌ 已移除 showCreateSheet) + @State private var editingNotebook: Notebook? + @State private var showDeleteAlert = false + @State private var notebookToDelete: Notebook? + @State private var showLoginSheet = false // 控制登录弹窗 + + // Toast 状态 + @State private var showToast = false + @State private var toastMessage = "" + + // ✅ 导航支持:使用全局 NavigationStore + @EnvironmentObject var navStore: NavigationStore + + var body: some View { + // ✅ 核心判断:登录 vs 游客 + if userManager.isLoggedIn { + // === 登录状态:展示笔记本列表 === + loggedInView + } else { + // === 游客状态:展示登录引导 === + guestView + } + } + + // MARK: - Logged In View (原有的列表逻辑) + + private var loggedInView: some View { + // ✅ NotebookListView 是独立视图,使用 growthPath(笔记相关) + NavigationStack(path: $navStore.growthPath) { + ZStack { + Color.bgPaper.ignoresSafeArea() + + if isLoading { + ProgressView() + } else if notebooks.isEmpty { + emptyStateView + } else { + // 笔记本列表内容 + notebookListContent + } + } + .navigationTitle("") // ✅ 不显示标题,这样返回按钮也不会显示文字 + .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: Notebook.self) { notebook in + NoteTreeView(notebook: notebook) + } + // ❌ MVP 版本:移除新增笔记本按钮 + // .toolbar { + // ToolbarItem(placement: .topBarTrailing) { + // Button { + // showCreateSheet = true + // } label: { + // Image(systemName: "plus") + // .font(.system(size: 18, weight: .semibold)) + // .foregroundColor(.brandVital) + // } + // } + // } + // Toast 提示 + .toast(message: toastMessage, isShowing: $showToast) + // ❌ MVP 版本:移除新增笔记本入口 + // .sheet(isPresented: $showCreateSheet) { + // NotebookFormSheet(mode: .create) { + // loadData() + // } + // .modifier(iPadSheetAdaptationModifier()) + // } + .sheet(item: $editingNotebook) { notebook in + NotebookFormSheet(mode: .edit(notebook)) { + Task { await loadDataAsync() } + } + .modifier(iPadSheetAdaptationModifier()) + } + .alert("删除笔记本", isPresented: $showDeleteAlert, presenting: notebookToDelete) { notebook in + Button("删除", role: .destructive) { + deleteNotebook(notebook) + } + Button("取消", role: .cancel) {} + } message: { notebook in + Text("确定要删除\"\(notebook.title)\"吗?\n里面的所有笔记也会被一并删除。") + } + } + .onAppear { + // ✅ P0 修复: 调用异步加载 + Task { await loadDataAsync() } + } + } + + // MARK: - Guest View (游客引导页) + + private var guestView: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 32) { + Spacer() + + // 1. 视觉插图 + ZStack { + Circle() + .fill(Color.brandVital.opacity(0.1)) + .frame(width: 120, height: 120) + + Image(systemName: "lock.doc.fill") // 使用锁+文档的图标 + .font(.system(size: 56)) + .foregroundColor(.brandVital) + } + .padding(.bottom, 16) + + // 2. 文案 + VStack(spacing: 12) { + Text("构建自己的知识体系") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.inkPrimary) + + Text("登录后,你可以创建专属笔记本\n将碎片灵感整理成结构化知识树") + .font(.body) + .foregroundColor(.inkSecondary) + .multilineTextAlignment(.center) + .lineSpacing(6) + } + + Spacer() + + // 3. 登录按钮 + Button { + // 触发登录逻辑 + showLoginSheet = true + } label: { + Text("立即登录") + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color.brandVital) + .cornerRadius(25) + .shadow(color: .brandVital.opacity(0.3), radius: 10, x: 0, y: 5) + } + .padding(.horizontal, 40) + .padding(.bottom, 60) // 底部留白 + } + } + .sheet(isPresented: $showLoginSheet) { + LoginView( + isLoggedIn: Binding( + get: { UserManager.shared.isLoggedIn }, + set: { UserManager.shared.isLoggedIn = $0 } + ), + onClose: { + showLoginSheet = false + } + ) + .modifier(iPadSheetAdaptationModifier()) + } + } + + // MARK: - Subviews & Actions + + // 抽离出来的列表内容,保持代码整洁 + private var notebookListContent: some View { + HStack(spacing: 0) { + Spacer() + + ScrollView { + LazyVStack(spacing: 16) { + ForEach(notebooks.filter { ($0.noteCount ?? 0) > 0 }) { notebook in + Button { + // ✅ P0 修复: 使用全局 path 跳转 + navStore.growthPath.append(notebook) + } label: { + NotebookCardView(notebook: notebook) + } + .buttonStyle(ScaleButtonStyle()) + .contextMenu { + Button { + editingNotebook = notebook + } label: { + Label("编辑信息", systemImage: "pencil") + } + + Button(role: .destructive) { + notebookToDelete = notebook + showDeleteAlert = true + } label: { + Label("删除", systemImage: "trash") + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 100) + } + .frame(maxWidth: horizontalSizeClass == .regular ? 640 : .infinity) + + Spacer() + } + // ✅ P0 修复: refreshable 调用异步方法 + .refreshable { await loadDataAsync() } + } + + // MARK: - Views + + private var emptyStateView: some View { + VStack(spacing: 24) { + Image(systemName: "book.closed") + .font(.system(size: 60)) + .foregroundColor(.inkSecondary.opacity(0.2)) + + VStack(spacing: 8) { + Text("开启知识体系") + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.inkPrimary) + + Text("笔记本将在创建笔记时自动创建") + .font(.body) + .foregroundColor(.inkSecondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Actions + + private func loadData() { + Task { + await loadDataAsync() + } + } + + // ✅ P0 修复: 拆分为 loadDataAsync,符合最佳实践 + private func loadDataAsync() async { + do { + let items = try await NotebookService.shared.getNotebooks() + await MainActor.run { + // ✅ P1 修复: 移除冗余排序,直接使用后端返回的数据 + self.notebooks = items + self.isLoading = false + } + } catch { + await MainActor.run { + self.isLoading = false + self.toastMessage = "加载失败: \(error.localizedDescription)" + self.showToast = true + } + } + } + + private func deleteNotebook(_ notebook: Notebook) { + Task { + do { + try await NotebookService.shared.deleteNotebook(id: notebook.id) + await MainActor.run { + if let index = notebooks.firstIndex(where: { $0.id == notebook.id }) { + notebooks.remove(at: index) + } + self.toastMessage = "笔记本已删除" + self.showToast = true + } + } catch { + await MainActor.run { + self.toastMessage = "删除失败: \(error.localizedDescription)" + self.showToast = true + } + } + } + } +} + +// ✅ 辅助组件:按压缩放 +struct ScaleButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.96 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} diff --git a/ios/WildGrowth/WildGrowth/NotebookService.swift b/ios/WildGrowth/WildGrowth/NotebookService.swift new file mode 100644 index 0000000..e0f8bb1 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/NotebookService.swift @@ -0,0 +1,106 @@ +import Foundation + +// MARK: - ✅ Phase 3: Notebook Service +class NotebookService { + static let shared = NotebookService() + private let apiClient = APIClient.shared + + private init() {} + + // MARK: - 创建笔记本 + func createNotebook(title: String, description: String? = nil, coverImage: String? = nil) async throws -> Notebook { + let request = CreateNotebookRequest( + title: title, + description: description, + coverImage: coverImage + ) + + // 将 Codable 结构体转换为字典 + let encoder = JSONEncoder() + guard let jsonData = try? encoder.encode(request), + let bodyDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw APIError.serverError("请求体编码失败") + } + + let response: NotebookResponse = try await apiClient.request( + endpoint: "/api/notebooks", + method: "POST", + body: bodyDict, + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("创建笔记本失败") + } + + return response.data.notebook + } + + // MARK: - 获取用户的所有笔记本 + func getNotebooks() async throws -> [Notebook] { + let response: NotebookListResponse = try await apiClient.request( + endpoint: "/api/notebooks", + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取笔记本列表失败") + } + + return response.data.notebooks + } + + // MARK: - 获取单个笔记本详情 + func getNotebook(id: String) async throws -> Notebook { + let response: NotebookResponse = try await apiClient.request( + endpoint: "/api/notebooks/\(id)", + method: "GET", + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("获取笔记本失败") + } + + return response.data.notebook + } + + // MARK: - 更新笔记本 + func updateNotebook(id: String, title: String? = nil, description: String? = nil, coverImage: String? = nil) async throws -> Notebook { + let request = UpdateNotebookRequest( + title: title, + description: description, + coverImage: coverImage + ) + + // 将 Codable 结构体转换为字典 + let encoder = JSONEncoder() + guard let jsonData = try? encoder.encode(request), + let bodyDict = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw APIError.serverError("请求体编码失败") + } + + let response: NotebookResponse = try await apiClient.request( + endpoint: "/api/notebooks/\(id)", + method: "PUT", + body: bodyDict, + requiresAuth: true + ) + + guard response.success else { + throw APIError.serverError("更新笔记本失败") + } + + return response.data.notebook + } + + // MARK: - 删除笔记本 + func deleteNotebook(id: String) async throws { + let _: EmptyResponse = try await apiClient.request( + endpoint: "/api/notebooks/\(id)", + method: "DELETE", + requiresAuth: true + ) + } +} diff --git a/ios/WildGrowth/WildGrowth/Preview Content/Preview Assets.xcassets/Contents.json b/ios/WildGrowth/WildGrowth/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WildGrowth/WildGrowth/PrivacyInfo.xcprivacy b/ios/WildGrowth/WildGrowth/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..4a433c9 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/PrivacyInfo.xcprivacy @@ -0,0 +1,38 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/ios/WildGrowth/WildGrowth/ProfileView.swift b/ios/WildGrowth/WildGrowth/ProfileView.swift new file mode 100644 index 0000000..cf6d908 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/ProfileView.swift @@ -0,0 +1,854 @@ +import SwiftUI +import Kingfisher + +// MARK: - Profile Navigation Destination +// ✅ 修复:统一使用枚举管理导航,确保 path 和视图栈完全同步 +enum ProfileNavigationDestination: Hashable { + case notebookList // 笔记本列表 + case notebook(Notebook) // 笔记列表(树) +} + +struct ProfileView: View { + @Binding var isLoggedIn: Bool + @StateObject private var userManager = UserManager.shared + @EnvironmentObject var navStore: NavigationStore + + // 数据源 + @State private var totalNoteCount: Int = 0 // ✅ 笔记总数 + + // UI 状态 + @State private var showAvatarPicker = false + @State private var showEditNameSheet = false + @State private var showEditProfileSheet = false + @State private var showSettingsDialog = false + @State private var showLoginSheet = false + // ✅ 修复:移除 showNotebookListView,改用 path 驱动导航 + + // Toast 状态 + @State private var toastMessage: String = "" + @State private var showToast: Bool = false + + @State private var activeAlert: ActiveAlert? + @State private var showAboutPage = false + + enum ActiveAlert: Identifiable { + case logout, deleteAccount + var id: Int { hashValue } + } + + let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + + var body: some View { + ScrollView(showsIndicators: false) { + if isLoggedIn { + // === 登录用户 UI === + VStack(spacing: 0) { + // 1. 顶部:赛博学习证 + VStack(spacing: 24) { // ✅ 修复:使用统一的spacing: 24,确保所有卡片之间的间距一致 + CyberLearningIDCard( + avatar: userManager.currentUser?.avatar, + avatarCacheBust: userManager.avatarCacheBust, + nickname: userManager.currentUser?.nickname ?? "未设置昵称", + digitalId: userManager.currentUser?.digitalId, + userId: userManager.currentUser?.id, // ✅ 传递userId用于生成ID + onAvatarTap: { showEditProfileSheet = true }, + onNameTap: { showEditProfileSheet = true }, + onCopyID: { + showToastMsg("ID 已复制") + } + ) + .id(userManager.avatarCacheBust) + .onAppear { + print("🔍 [头像调试] 电子卡 onAppear,currentUser.avatar: \(userManager.currentUser?.avatar ?? "nil")") + Task { try? await userManager.fetchUserProfile() } + } + .padding(.horizontal, 20) + + // 2. 一体化数据卡片 (统计 + 经验) + VStack(spacing: 0) { + // A. 顶部大数字 + HStack { + StatColumn( + num: "\(userManager.studyStats.time)", + unit: "分钟", + label: "专注" + ) + Rectangle().fill(Color.inkSecondary.opacity(0.1)).frame(width: 1, height: 24) + StatColumn( + num: "\(userManager.studyStats.lessons)", + unit: "节", + label: "已完成" + ) + } + .padding(.top, 20) + .padding(.bottom, 8) + } + .background(Color.bgWhite) + .cornerRadius(16) + .shadow(color: Color.black.opacity(0.03), radius: 8, y: 4) + .padding(.horizontal, 20) + + // ✅ 3. 笔记入口卡片(激励模块下方,只有有笔记时才显示) + if totalNoteCount > 0 { + NoteEntryCard(totalCount: totalNoteCount) { + // ✅ 修复:使用 path 导航,而不是 isPresented + navStore.profilePath.append(ProfileNavigationDestination.notebookList) + } + .padding(.horizontal, 20) + } + } + .padding(.top, 20) + .padding(.bottom, 30) + + // ✅ 计划已移到 GrowthView,笔记保留在个人中心 + } + } else { + // === 游客模式 === + loginPromptCard + .padding(.top, 40) + } + } + .background(Color.bgPaper.ignoresSafeArea()) + .toast(message: toastMessage, isShowing: $showToast) + .navigationTitle("") // ✅ 设置空标题,让返回按钮只显示箭头 + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isLoggedIn { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { showSettingsDialog = true }) { + Text("账户") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.inkSecondary) + } + } + } + } + // ✅ 保留 navigationDestination,用于其他可能的导航(如从其他页面跳转) + .navigationDestination(for: CourseNavigation.self) { destination in + switch destination { + case .map(let courseId): + MapView(courseId: courseId, navigationPath: $navStore.profilePath) + case .player(let courseId, let nodeId, let isLastNode, let courseTitle): + VerticalScreenPlayerView( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: nil, + navigationPath: $navStore.profilePath, + isLastNode: isLastNode, + courseTitle: courseTitle + ) + } + } + // ✅ 修复:统一使用 ProfileNavigationDestination 管理所有导航 + .navigationDestination(for: ProfileNavigationDestination.self) { destination in + switch destination { + case .notebookList: + NotebookListViewContent() + .environmentObject(navStore) + case .notebook(let notebook): + NoteTreeView(notebook: notebook) + } + } + .confirmationDialog("账户", isPresented: $showSettingsDialog, titleVisibility: .visible) { + Button("关于电子成长") { showAboutPage = true } + Button("退出登录") { activeAlert = .logout } + Button("注销账号", role: .destructive) { activeAlert = .deleteAccount } + Button("取消", role: .cancel) {} + } + .sheet(isPresented: $showEditNameSheet) { + EditNameSheet(userManager: userManager).presentationDetents([.medium]) + } + .sheet(isPresented: $showLoginSheet) { + LoginView(isLoggedIn: $isLoggedIn, onClose: { showLoginSheet = false }) + } + .sheet(isPresented: $showAboutPage) { + AboutView() + } + .alert(item: $activeAlert) { alertType in + switch alertType { + case .logout: + return Alert( + title: Text("退出登录"), + message: Text("确定要退出当前账号吗?"), + primaryButton: .destructive(Text("退出")) { + userManager.logout() + isLoggedIn = false + }, + secondaryButton: .cancel() + ) + case .deleteAccount: + return Alert( + title: Text("注销账号"), + message: Text("注销后数据将无法恢复,确定继续吗?"), + primaryButton: .destructive(Text("注销")) { + Task { + try? await userManager.deleteAccount() + isLoggedIn = false + } + }, + secondaryButton: .cancel() + ) + } + } + // ✅ 移除课程删除逻辑,已移到 GrowthView + .onAppear { + if isLoggedIn { + loadNoteCount() + Task { + do { + try await userManager.fetchUserProfile() + } catch { + // 静默失败,不显示错误提示 + } + } + } + } + .onChange(of: isLoggedIn) { newValue in + if newValue { + loadNoteCount() + Task { try? await userManager.fetchUserProfile() } + } else { + totalNoteCount = 0 + } + } + // Sheet: 头像选择(保留,向后兼容) + .sheet(isPresented: $showAvatarPicker) { + AvatarPicker(isPresented: $showAvatarPicker) { image in + Task { + if let url = try? await UserService.shared.uploadAvatar(image: image) { + try? await userManager.updateAvatar(url) + } + } + } + } + // Sheet: 统一编辑浮窗 + .sheet(isPresented: $showEditProfileSheet) { + if let user = userManager.currentUser { + EditProfileSheet( + currentNickname: user.nickname, + currentAvatarUrl: user.avatar ?? "", + onSaveAction: { newImage, newName in + // 1. 上传头像(如果有) + if let img = newImage { + if let url = try? await UserService.shared.uploadAvatar(image: img) { + try? await userManager.updateAvatar(url) + } + } + // 2. 更新昵称(如果有修改) + if newName != user.nickname { + try? await userManager.updateNickname(newName) + } + } + ) + } + } + } + + // MARK: - 业务逻辑 + + // ✅ 移除 loadCourses 和 removeCourse,已移到 GrowthView + + func showToastMsg(_ message: String) { + toastMessage = message + showToast = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showToast = false + } + } + + // MARK: - 笔记统计 + + /// 加载笔记总数 + func loadNoteCount() { + guard userManager.isLoggedIn else { + totalNoteCount = 0 + return + } + + Task { + do { + let notebooks = try await NotebookService.shared.getNotebooks() + let total = notebooks.reduce(0) { $0 + ($1.noteCount ?? 0) } + await MainActor.run { + self.totalNoteCount = total + } + } catch { + await MainActor.run { + self.totalNoteCount = 0 + } + } + } + } + + // MARK: - 辅助视图 + + var loginPromptCard: some View { + VStack(spacing: 20) { + Image(systemName: "lock.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.inkSecondary.opacity(0.3)) + + Text("登录后创建属于你的学习内容") + .font(.subheadline) + .foregroundColor(.inkSecondary) + + Button(action: { showLoginSheet = true }) { + Text("立即登录") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background(Color.brandVital) + .cornerRadius(24) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } +} + +// MARK: - 子组件 + +// MARK: - 优化后的统计列组件(强化数字展示) +struct StatColumn: View { + let num: String // ✅ 还原参数名 + let unit: String + let label: String // ✅ 还原参数名 + + var body: some View { + VStack(alignment: .center, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(num) + .font(.statDisplayLarge()) // 32pt Semibold Rounded + .foregroundColor(.inkPrimary) // 数字黑色,与标题一致 + + Text(unit) + .font(.label()) // 13pt + .foregroundColor(.inkSecondary) + } + + Text(label) + .font(.labelMedium()) // 13pt Medium + .foregroundColor(.inkSecondary) + } + .frame(maxWidth: .infinity) // 居中对齐于各自半区 + } +} + +// MARK: - 小红书风格课程卡片 +struct ProfileCourseCard: View { + let course: JoinedCourse + // 回调是可选的,安全解包调用 + var onRetry: (() -> Void)? = nil + // ✅ AI 停止生成回调已删除 + + // ✅ 平滑进度相关状态 + @State private var displayedProgress: Int = 0 + @State private var progressTimer: Timer? + + private var safeTitle: String { + if course.title.isEmpty || course.title == "生成中..." { + return "课程创建中" + } + return course.title + } + + // 使用存储的主题色生成渐变,否则使用默认渐变池 + private var currentGradient: LinearGradient { + if let hex = course.themeColor, !hex.isEmpty { + let color = Color(hex: hex) + return LinearGradient( + gradient: Gradient(colors: [color, color.opacity(0.8)]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + return LinearGradient.courseCardGradients[abs(course.id.hashValue) % LinearGradient.courseCardGradients.count] + } + + // 辅助判断是否有标签,用于控制布局逻辑 + private var hasTag: Bool { + if let status = course.statusText, !status.isEmpty { + return true + } + return false + } + + /// 封面图 URL(相对路径拼 baseURL) + private var coverImageURL: URL? { + guard let coverImage = course.coverImage, !coverImage.isEmpty else { return nil } + let path = coverImage.hasPrefix("/") ? coverImage : "/" + coverImage + let full = coverImage.hasPrefix("http") ? coverImage : (APIClient.shared.baseURL + path) + return URL(string: full) + } + + /// 占位图视图(themeColor 渐变 + 水印图标,非统一占位图) + @ViewBuilder + private func placeholderView(geo: GeometryProxy) -> some View { + ZStack { + currentGradient + Image(systemName: course.watermarkIconName) + .font(.system(size: geo.size.width * 0.4)) + .foregroundColor(.white.opacity(0.2)) + } + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + // === Layer 1: 封面图或占位图(渐变+水印) === + Group { + if let url = coverImageURL { + KFImage(url) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } else { + placeholderView(geo: geo) + .frame(width: geo.size.width, height: geo.size.height) + } + } + + // === Layer 2: 水印叠层(有图无图都显示) === + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: course.watermarkIconName) + .font(.system(size: geo.size.width * 1.0)) + .foregroundColor(.white.opacity(0.15)) + .rotationEffect(.degrees(-15)) + .offset(x: geo.size.width * 0.3, y: geo.size.height * 0.2) + } + } + .clipped() + + // === Layer 3: 核心内容区 (标题) === + VStack(alignment: .leading, spacing: 6) { + + // ⚠️ 已移除顶部 Spacer,由外层 padding(20) 接管,保持完美 20px 间距 + + // 大标题 + Text(safeTitle) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .lineSpacing(6) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) + // ✅ 极致体验修正: + // 有角标:避让 16px,防止撞到金色丝带。 + // 无角标:保留 4px 微小缓冲,防止文字视觉上过于贴近右侧边界,更透气。 + .padding(.trailing, hasTag ? 16 : 4) + + Spacer() + } + // ✅ 恢复四周 Padding,确保上下左右都有统一的 20px 呼吸感 + .padding(20) + + } + // ✅ 修正: 挂载在 ZStack 上 + .overlay(alignment: .topTrailing) { + // === Layer 4: 右上角·微信读书风角标 (WeChat Orange) === + if hasTag, let status = course.statusText { + ZStack(alignment: .center) { + // 1. 背景条 (微信读书同款·哑光暖橙) + Rectangle() + .fill(.statusRibbonGradient) + // ✨ 尺寸锁定:宽度 80 保持精致,高度 18 + .frame(width: 80, height: 18) + + // 2. 文字 (纯白,对比度最高) - 居中显示在丝带中间 + Text(status) + .font(.system(size: 8, weight: .bold)) // 8px 微型字 + .foregroundColor(.white) // ✅ 橙底白字是微信读书的标准 + .offset(x: 7) // ✅ 向右微调,让文字在水平方向居中 + } + // 3. 几何定位 (精准计算) + // 先旋转 + .rotationEffect(.degrees(45)) + // ✨ 关键修复:使用极小的 offset,让角标几乎贴在角落,确保"已完成"完整显示 + // 减小偏移量,让角标更靠近角落,文字能完整显示 + .offset(x: 0, y: 0) + // 4. 微弱阴影,增加一点立体感,防止和背景融为一体 + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) + .allowsHitTesting(false) + } + } + + // === Layer 5: 生成中进度条(UI 优化版:尺寸限制 + 毛玻璃效果)=== + if course.isGeneratingStatus { + // ✅ 优化:计算合理尺寸,最大不超过 100pt + let outerSize = min(geo.size.width * 0.45, 100) + let innerSize = outerSize * 0.8 // 内部进度条稍微小一圈 + + ZStack { + // 1. 毛玻璃底板 (仅限制在圆环区域,不压暗整个背景) + Circle() + .fill(.ultraThinMaterial) + .frame(width: outerSize, height: outerSize) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + + // 2. 进度圆环(使用平滑进度) + Circle() + .trim(from: 0, to: CGFloat(displayedProgress) / 100.0) + .stroke( + Color.white, + style: StrokeStyle(lineWidth: 5, lineCap: .round) + ) + .frame(width: innerSize, height: innerSize) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.3), value: displayedProgress) + + // 3. 进度文字(使用平滑进度) + Text("\(displayedProgress)%") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(.white) // ✅ 直接使用白色,不要 colorInvert + .minimumScaleFactor(0.8) // 确保文字在小卡片上不换行 + } + .frame(maxWidth: .infinity, maxHeight: .infinity) // 居中定位 + .onAppear { + startSmoothProgress() + } + .onDisappear { + stopSmoothProgress() + } + } + + // === Layer 6: 生成失败遮罩 + 删除按钮 === + if course.isGenerationFailed { + VStack { + Spacer() + HStack { + Spacer() + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 24)) + .foregroundColor(.white) + Text("创建失败") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + + // ✅ 删除课程按钮 + Button { + // 通过 NotificationCenter 通知父视图删除课程 + NotificationCenter.default.post( + name: NSNotification.Name("DeleteCourse"), + object: nil, + userInfo: ["courseId": course.id] + ) + } label: { + Text("删除课程") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Color.red.opacity(0.8)) + .cornerRadius(8) + } + } + .padding(.trailing, 16) + .padding(.bottom, 16) + } + } + } + } + .aspectRatio(3/4, contentMode: .fit) + // ✅ 修正: ClipShape 在最外层,完美裁剪角标 + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + .shadow(color: Color.brandVital.opacity(0.3), radius: 6, x: 0, y: 3) + } + + // MARK: - 平滑进度管理 + + private func startSmoothProgress() { + // 停止旧的定时器 + stopSmoothProgress() + + // 获取任务开始时间(使用 joinedAt 作为开始时间) + let startTime = SmoothProgressCalculator.parseDate(from: course.joinedAt) + let realProgress = course.generationProgress ?? 0 + + // 立即计算一次 + displayedProgress = SmoothProgressCalculator.calculate( + startTime: startTime, + realProgress: realProgress + ) + + // 每0.1秒更新一次进度 + // 注意:ProfileCourseCard 是 struct,Timer 会在 onDisappear 时被清理 + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + // 由于 Timer 会在 onDisappear 时被清理,这里不需要 weak + let startTime = SmoothProgressCalculator.parseDate(from: course.joinedAt) + let realProgress = course.generationProgress ?? 0 + displayedProgress = SmoothProgressCalculator.calculate( + startTime: startTime, + realProgress: realProgress + ) + } + } + + private func stopSmoothProgress() { + progressTimer?.invalidate() + progressTimer = nil + } +} + +// MARK: - 笔记入口卡片 +struct NoteEntryCard: View { + let totalCount: Int + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + // 左侧:标题 + Text("笔记") + .font(.h3()) // 16pt Medium + .foregroundColor(.inkPrimary) + + Spacer() + + // 右侧:数量(如果有笔记才显示) + if totalCount > 0 { + Text("\(totalCount)个") + .font(.label()) // 13pt Regular + .foregroundColor(.inkSecondary) + } + + // 箭头图标 + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.inkSecondary.opacity(0.4)) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color.bgWhite) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.02), radius: 4, y: 1) + } + .buttonStyle(.plain) + } +} + +// MARK: - 笔记列表内容(从 NotebookListView 抽取) +private struct NotebookListViewContent: View { + @ObservedObject var userManager = UserManager.shared + @EnvironmentObject var navStore: NavigationStore + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @State private var notebooks: [Notebook] = [] + @State private var isLoading = true + @State private var editingNotebook: Notebook? + @State private var showDeleteAlert = false + @State private var notebookToDelete: Notebook? + @State private var showToast = false + @State private var toastMessage = "" + + var body: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + if !userManager.isLoggedIn { + // 游客状态(通常不会到达这里,因为 ProfileView 已检查登录) + EmptyView() + } else if isLoading { + ProgressView() + } else if notebooks.isEmpty { + emptyStateView + } else { + notebookListContent + } + } + .navigationTitle("笔记") + .navigationBarTitleDisplayMode(.inline) // ✅ 修复:改为inline模式,居中显示标题 + .toast(message: toastMessage, isShowing: $showToast) + .sheet(item: $editingNotebook) { notebook in + NotebookFormSheet(mode: .edit(notebook)) { + Task { await loadDataAsync() } + } + .modifier(iPadSheetAdaptationModifier()) + } + .alert("删除笔记本", isPresented: $showDeleteAlert, presenting: notebookToDelete) { notebook in + Button("删除", role: .destructive) { + deleteNotebook(notebook) + } + Button("取消", role: .cancel) {} + } message: { notebook in + Text("确定要删除\"\(notebook.title)\"吗?\n里面的所有笔记也会被一并删除。") + } + // ✅ 修复:移除重复的 navigationDestination,统一由 ProfileView 的 ProfileNavigationDestination 处理 + .onAppear { + Task { await loadDataAsync() } + } + } + + private var notebookListContent: some View { + HStack(spacing: 0) { + Spacer() + + ScrollView { + LazyVStack(spacing: 16) { + ForEach(notebooks.filter { ($0.noteCount ?? 0) > 0 }) { notebook in + Button { + // ✅ 修复:使用 ProfileNavigationDestination 枚举导航 + navStore.profilePath.append(ProfileNavigationDestination.notebook(notebook)) + } label: { + NotebookCardView(notebook: notebook) + } + .buttonStyle(.plain) + .contextMenu { + Button { + editingNotebook = notebook + } label: { + Label("编辑信息", systemImage: "pencil") + } + + Button(role: .destructive) { + notebookToDelete = notebook + showDeleteAlert = true + } label: { + Label("删除", systemImage: "trash") + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 100) + } + .frame(maxWidth: horizontalSizeClass == .regular ? 640 : .infinity) + .refreshable { await loadDataAsync() } + + Spacer() + } + } + + private var emptyStateView: some View { + VStack(spacing: 24) { + Image(systemName: "book.closed") + .font(.system(size: 60)) + .foregroundColor(.inkSecondary.opacity(0.2)) + + VStack(spacing: 8) { + Text("开启知识体系") + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.inkPrimary) + + Text("笔记本将在创建笔记时自动创建") + .font(.body) + .foregroundColor(.inkSecondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func loadDataAsync() async { + do { + let items = try await NotebookService.shared.getNotebooks() + await MainActor.run { + self.notebooks = items + self.isLoading = false + } + } catch { + await MainActor.run { + self.isLoading = false + self.toastMessage = "加载失败: \(error.localizedDescription)" + self.showToast = true + } + } + } + + private func deleteNotebook(_ notebook: Notebook) { + Task { + do { + try await NotebookService.shared.deleteNotebook(id: notebook.id) + await MainActor.run { + if let index = notebooks.firstIndex(where: { $0.id == notebook.id }) { + notebooks.remove(at: index) + } + self.toastMessage = "笔记本已删除" + self.showToast = true + } + } catch { + await MainActor.run { + self.toastMessage = "删除失败: \(error.localizedDescription)" + self.showToast = true + } + } + } + } +} + +// MARK: - 编辑昵称弹窗 +struct EditNameSheet: View { + @ObservedObject var userManager: UserManager + @Environment(\.presentationMode) var presentationMode + @State private var tempName: String = "" + @State private var isLoading = false + + var body: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + VStack(spacing: 24) { + Text("修改昵称") + .font(.heading()) + .foregroundColor(.inkPrimary) + .padding(.top, 32) + HStack { + TextField("请输入新昵称", text: $tempName) + .font(.bodyText()) + .foregroundColor(.inkPrimary) + if !tempName.isEmpty { + Button(action: { tempName = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.inkSecondary) + } + } + } + .padding(16) + .background(Color.white) + .cornerRadius(16) + .submitLabel(.done) + Spacer() + Button(action: { + guard !tempName.isEmpty else { return } + isLoading = true + Task { + try? await userManager.updateNickname(tempName) + isLoading = false + presentationMode.wrappedValue.dismiss() + } + }) { + if isLoading { + ProgressView().tint(.white) + } else { + Text("保存") + .font(.system(size: 18, weight: .bold)) + } + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(tempName.isEmpty ? Color.gray.opacity(0.3) : Color.brandMoss) + .cornerRadius(28) + .disabled(tempName.isEmpty || isLoading) + .padding(.bottom, 20) + } + .padding(.horizontal, 24) + } + .onAppear { + tempName = userManager.currentUser?.nickname ?? "" + } + } +} diff --git a/ios/WildGrowth/WildGrowth/RichTextView.swift b/ios/WildGrowth/WildGrowth/RichTextView.swift new file mode 100644 index 0000000..105f76a --- /dev/null +++ b/ios/WildGrowth/WildGrowth/RichTextView.swift @@ -0,0 +1,266 @@ +import SwiftUI +import UIKit + +// MARK: - 1. Color Bridge +extension UIColor { + convenience init(_ color: Color) { + if let cgColor = color.cgColor { + self.init(cgColor: cgColor) + } else { + self.init(cgColor: UIColor.label.cgColor) + } + } +} + +// MARK: - 2. Theme Mapping (升级版:支持多种颜色) +fileprivate struct RichTextTheme { + // 基础色 + static let bodyColor = UIColor(Color.inkBody) + static let quoteColor = UIColor(Color.inkSecondary) // #8E8E93 + + // 品牌强调色 + static let colorVital = UIColor(Color.brandVital) // 蓝 #2266FF + static let colorIris = UIColor(Color.cyberIris) // 紫 + static let colorNeon = UIColor(Color.cyberNeon) // 粉 + + // 字体 + static let bodyFont = UIFont.systemFont(ofSize: 19, weight: .regular) + static let boldFont = UIFont.systemFont(ofSize: 19, weight: .bold) + static let quoteFont = UIFont.italicSystemFont(ofSize: 19) // 引用使用斜体 + + // 排版 + static let lineSpacing: CGFloat = 6 + static let paragraphSpacing: CGFloat = 20 + static let quoteIndent: CGFloat = 16 // 引用缩进 +} + +// MARK: - 3. 自定义 TextView (双重保险) +class SingleActionTextView: UITextView { + + // 👇👇👇【新增】复制动作的回调钩子 + var onCopyAction: (() -> Void)? + + // 👇👇👇【新增】重写系统 Copy 方法 + override func copy(_ sender: Any?) { + // 1. 必须调用 super,确保文字被写入系统剪贴板 + super.copy(sender) + + // 2. ✨【核心功能】瞬间清除选中状态 (蓝框消失) + self.selectedRange = NSRange(location: 0, length: 0) + + // 3. 触发回调 + onCopyAction?() + } + + // 3.1 菜单控制:只允许复制 + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + return action == #selector(copy(_:)) + } + + // 3.2 禁用拖拽交互 + override var textDragInteraction: UIDragInteraction? { return nil } + + // 3.3 第二道防线:Delegate 拦截 + // 虽然我们在 makeUIView 里已经物理禁用了 Tap,保留这个作为兜底 + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer is UITapGestureRecognizer { return false } // 再次确认拒绝点击 + return true // 允许长按(LongPress)和拖动(Pan) + } +} + +// MARK: - 4. SwiftUI Wrapper +struct RichTextView: UIViewRepresentable { + let paragraphs: [String] + let width: CGFloat + @Binding var dynamicHeight: CGFloat + + // 👇👇👇【新增】对外暴露的回调参数,默认为 nil + var onCopy: (() -> Void)? = nil + + func makeUIView(context: Context) -> UITextView { + let textView = SingleActionTextView() + + // 👇👇👇【新增】绑定回调 + textView.onCopyAction = onCopy + + // --- 基础配置 --- + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentCompressionResistancePriority(.required, for: .vertical) + + // --- 🔨 强硬派手段:主动禁用点击手势 --- + // 这是解决"点两下"和"误触选中"最彻底的方法 + // 原理:直接把负责点击/双击的识别器关掉,事件就会自动穿透给父视图 + if let gestures = textView.gestureRecognizers { + for gesture in gestures { + // 找到所有的点击手势 (Tap),包含单击和双击 + if gesture is UITapGestureRecognizer { + gesture.isEnabled = false // 🚫 彻底禁用 + } + // 注意:我们没有碰 UILongPressGestureRecognizer,所以长按复制依然有效 + } + } + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + guard width > 0 else { return } + + // 👇👇👇【新增】确保回调同步更新 + if let customTextView = uiView as? SingleActionTextView { + customTextView.onCopyAction = onCopy + } + + // 构建富文本 + let newAttributedText = createAttributedString(from: paragraphs) + + if uiView.attributedText?.string != newAttributedText.string { + uiView.attributedText = newAttributedText + } + + // 计算高度 + let targetSize = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude) + let calculatedSize = uiView.sizeThatFits(targetSize) + + if abs(dynamicHeight - calculatedSize.height) > 1.0 { + DispatchQueue.main.async { + self.dynamicHeight = calculatedSize.height + } + } + } + + // MARK: - 核心解析逻辑 (修复版) + private func createAttributedString(from paragraphs: [String]) -> NSAttributedString { + let finalAttributedString = NSMutableAttributedString() + + for (index, rawText) in paragraphs.enumerated() { + // 1. 预处理:判断引用 + var isQuote = false + var textToProcess = rawText + + if rawText.hasPrefix("") && rawText.hasSuffix("") { + isQuote = true + textToProcess = String(rawText.dropFirst(7).dropLast(8)) + } + + // 2. 建立基础属性 + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = RichTextTheme.lineSpacing + paragraphStyle.paragraphSpacing = RichTextTheme.paragraphSpacing + paragraphStyle.lineBreakMode = .byWordWrapping + + var currentFont = RichTextTheme.bodyFont + var currentColor = RichTextTheme.bodyColor + var prefixString = "" + + if isQuote { + paragraphStyle.firstLineHeadIndent = RichTextTheme.quoteIndent + paragraphStyle.headIndent = RichTextTheme.quoteIndent + currentFont = RichTextTheme.quoteFont + currentColor = RichTextTheme.quoteColor + prefixString = "▎" // 引用竖线符号 + } + + let baseAttributes: [NSAttributedString.Key: Any] = [ + .font: currentFont, + .foregroundColor: currentColor, + .paragraphStyle: paragraphStyle + ] + + // 3. 初始化段落 + let paragraphMutableString = NSMutableAttributedString(string: prefixString + textToProcess, attributes: baseAttributes) + + // 引用竖线染色 + if isQuote && !prefixString.isEmpty { + paragraphMutableString.addAttribute(.foregroundColor, value: RichTextTheme.colorVital, range: NSRange(location: 0, length: 1)) + } + + // 4. 解析行内标签 (Inline Styles) + + // ✅ 核心修复:Highlight 正则 - 兼容单引号和双引号 + // 修改点: + // 这样既能匹配 也能匹配 + _ = applyInlineStyle(to: paragraphMutableString, + pattern: "(.*?)", + attributes: [.foregroundColor: RichTextTheme.colorVital, .font: RichTextTheme.boldFont]) + + // Bold + _ = applyInlineStyle(to: paragraphMutableString, + pattern: "(.*?)", + attributes: [.font: RichTextTheme.boldFont]) + + // Color: Vital (兼容单双引号) + _ = applyInlineStyle(to: paragraphMutableString, + pattern: "(.*?)", + attributes: [.foregroundColor: RichTextTheme.colorVital, .font: RichTextTheme.boldFont]) + + // Color: Iris (兼容单双引号) + _ = applyInlineStyle(to: paragraphMutableString, + pattern: "(.*?)", + attributes: [.foregroundColor: RichTextTheme.colorIris, .font: RichTextTheme.boldFont]) + + // Color: Neon (兼容单双引号) + _ = applyInlineStyle(to: paragraphMutableString, + pattern: "(.*?)", + attributes: [.foregroundColor: RichTextTheme.colorNeon, .font: RichTextTheme.boldFont]) + + // 5. 拼接 + finalAttributedString.append(paragraphMutableString) + + // 6. 换行 + if index < paragraphs.count - 1 { + finalAttributedString.append(NSAttributedString(string: "\n", attributes: baseAttributes)) + } + } + + return finalAttributedString + } + + // 辅助方法:正则替换 + @discardableResult + private func applyInlineStyle(to attributedString: NSMutableAttributedString, pattern: String, attributes: [NSAttributedString.Key: Any]) -> Int { + // 增加 .caseInsensitive 选项,忽略大小写,防止 SPAN/span 的差异导致匹配失败 + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + print("⚠️ [WARNING] Failed to create regex for pattern: \(pattern)") + return 0 + } + + let string = attributedString.string as NSString + let matches = regex.matches(in: attributedString.string, options: [], range: NSRange(location: 0, length: string.length)) + + // 倒序处理防止 Range 偏移 + for match in matches.reversed() { + if match.numberOfRanges > 1 { + let contentRange = match.range(at: 1) + let fullTagRange = match.range + + let contentText = string.substring(with: contentRange) + + // 🔥 核心修复:如果标签内容是空的,直接移除整个标签(不替换) + if contentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + print("🔧 [FIX] Removing empty tag at range \(fullTagRange)") + attributedString.deleteCharacters(in: fullTagRange) + continue + } + + // 获取原有属性并覆盖新属性 + var mergedAttributes = attributedString.attributes(at: fullTagRange.location, effectiveRange: nil) + for (key, value) in attributes { + mergedAttributes[key] = value + } + + let newStyledString = NSAttributedString(string: contentText, attributes: mergedAttributes) + + attributedString.replaceCharacters(in: fullTagRange, with: newStyledString) + } + } + + return matches.count + } +} diff --git a/ios/WildGrowth/WildGrowth/ScrollDetection.swift b/ios/WildGrowth/WildGrowth/ScrollDetection.swift new file mode 100644 index 0000000..4b4fcf5 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/ScrollDetection.swift @@ -0,0 +1,40 @@ +import SwiftUI + +// MARK: - 滚动位置检测 Key +// 用于传递 Block 的位置信息 +struct BlockFramePreferenceData: Equatable { + let id: String + let index: Int + let rect: CGRect +} + +struct BlockFramePreferenceKey: PreferenceKey { + typealias Value = [BlockFramePreferenceData] + static var defaultValue: [BlockFramePreferenceData] = [] + + static func reduce(value: inout [BlockFramePreferenceData], nextValue: () -> [BlockFramePreferenceData]) { + let next = nextValue() + if value.isEmpty { + value = next + return + } + + // 字典合并去重 + var dict = Dictionary(uniqueKeysWithValues: value.map { ($0.id, $0) }) + for item in next { + dict[item.id] = item + } + + // ✅ 修复:显式转换为 Array 并排序 + value = Array(dict.values).sorted { $0.index < $1.index } + } +} + +// MARK: - "下一节"按钮位置检测 Key +// 用于检测"下一节"按钮是否可见 +struct NextButtonPositionKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} diff --git a/ios/WildGrowth/WildGrowth/SplashView.swift b/ios/WildGrowth/WildGrowth/SplashView.swift new file mode 100644 index 0000000..554bce2 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/SplashView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct SplashView: View { + // 接收登录状态绑定,用于传递给 MainTabView + @Binding var isLoggedIn: Bool + + @State private var isActive = false + @State private var opacity = 0.0 + + var body: some View { + if isActive { + // 倒计时结束后,无缝切换到主页架构 + MainTabView(isLoggedIn: $isLoggedIn) + } else { + ZStack { + // ✅ 1. 使用 DesignSystem 定义的静态属性 (Extension) + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 24) { + // Logo (保持原样,信任 Assets) + Image("AppLogo") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + + VStack(spacing: 12) { + // ✅ 2. App 名称 - 使用 .inkPrimary + Text("电子成长") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.inkPrimary) + .tracking(2) + + // ✅ 3. Slogan - 使用 .inkSecondary + Text("无痛学习的魔法") + .font(.system(size: 15, weight: .regular)) + .foregroundColor(.inkSecondary) + .multilineTextAlignment(.center) + } + } + // 微调位置 + .padding(.bottom, 40) + } + .opacity(opacity) + .onAppear { + // 1. 检查本地 Token 状态 + UserManager.shared.checkLoginStatus() + + // 2. 动画时长对齐 (0.5s) + withAnimation(.easeIn(duration: 0.5)) { + self.opacity = 1.0 + } + + // 3. 停留时间对齐 (1.0s) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation(.easeInOut(duration: 0.5)) { + self.isActive = true + } + } + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/TaskStatusPoller.swift b/ios/WildGrowth/WildGrowth/TaskStatusPoller.swift new file mode 100644 index 0000000..3d3b1a6 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/TaskStatusPoller.swift @@ -0,0 +1,143 @@ +import Foundation +import SwiftUI + +// MARK: - 任务状态轮询器 +// 负责定时轮询任务状态,并在状态变化时通知观察者 + +@MainActor +class TaskStatusPoller: ObservableObject { + // MARK: - Published Properties + + @Published var status: TaskStatus = .pending + @Published var progress: Int = 0 + @Published var currentStep: String? + @Published var errorMessage: String? + @Published var courseId: String? + @Published var taskCreatedAt: String? // ✅ 新增:任务创建时间(ISO 8601格式) + + // MARK: - Private Properties + + private let taskId: String + private var timer: Timer? + private let pollInterval: TimeInterval = 2.0 // 2秒轮询一次 + private let maxPollTime: TimeInterval = 900.0 // 最多轮询15分钟 + private var startTime: Date? + private var isPolling: Bool = false + + // MARK: - Callbacks + + var onStatusChanged: ((TaskStatus) -> Void)? + var onCompleted: ((String) -> Void)? // courseId + var onFailed: ((String) -> Void)? // errorMessage + var onTimeout: (() -> Void)? + + // MARK: - Initialization + + init(taskId: String) { + self.taskId = taskId + } + + // MARK: - Public Methods + + /// 开始轮询 + func start() { + guard !isPolling else { + print("⚠️ [TaskStatusPoller] 已经在轮询中,跳过") + return + } + + print("🔵 [TaskStatusPoller] 开始轮询: taskId=\(taskId)") + isPolling = true + startTime = Date() + + // 立即执行一次(异步,不阻塞主线程) + Task { @MainActor in + print("🔵 [TaskStatusPoller] 立即执行第一次轮询") + await poll() + } + + // 设置定时器 + timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.poll() + } + } + + print("✅ [TaskStatusPoller] 轮询器已启动,定时器已设置") + } + + /// 停止轮询 + func stop() { + isPolling = false + timer?.invalidate() + timer = nil + } + + // MARK: - Private Methods + + private func poll() async { + guard isPolling else { + print("⚠️ [TaskStatusPoller] 轮询已停止,跳过") + return + } + + print("🔵 [TaskStatusPoller] 开始轮询: taskId=\(taskId)") + + // 检查超时 + if let startTime = startTime, + Date().timeIntervalSince(startTime) > maxPollTime { + print("⏰ [TaskStatusPoller] 轮询超时") + stop() + onTimeout?() + return + } + + do { + print("🔵 [TaskStatusPoller] 调用 getTaskStatus API...") + let statusData = try await AICourseService.shared.getTaskStatus(taskId: taskId) + print("✅ [TaskStatusPoller] 获取状态成功: status=\(statusData.status), progress=\(statusData.progress)") + + // 更新状态 + let newStatus = TaskStatus(rawValue: statusData.status) ?? .pending + + if newStatus != status { + print("🔄 [TaskStatusPoller] 状态变化: \(status) -> \(newStatus)") + status = newStatus + onStatusChanged?(newStatus) + } + + progress = statusData.progress + currentStep = statusData.currentStep + errorMessage = statusData.errorMessage + courseId = statusData.courseId + taskCreatedAt = statusData.createdAt // ✅ 新增:保存任务创建时间 + + // 检查完成状态 + if newStatus.isCompleted { + print("✅ [TaskStatusPoller] 任务完成") + stop() + // courseId 是必需的,直接使用 + onCompleted?(statusData.courseId) + } else if newStatus.isFailed { + print("❌ [TaskStatusPoller] 任务失败") + stop() + onFailed?(statusData.errorMessage ?? "生成失败") + } + + } catch { + // 轮询失败不停止,继续尝试 + print("❌ [TaskStatusPoller] 轮询失败: \(error.localizedDescription)") + print("❌ [TaskStatusPoller] 错误类型: \(type(of: error))") + } + } + + deinit { + // 只捕获 timer 引用并在 MainActor 上失效,避免闭包捕获 self 导致 Swift 6 报错 + let t = timer + timer = nil + isPolling = false + Task { @MainActor in + t?.invalidate() + } + } +} diff --git a/ios/WildGrowth/WildGrowth/ToastView.swift b/ios/WildGrowth/WildGrowth/ToastView.swift new file mode 100644 index 0000000..2ec5bcb --- /dev/null +++ b/ios/WildGrowth/WildGrowth/ToastView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +// MARK: - Toast 类型枚举 +enum ToastType { + case success + case error + case info +} + +// MARK: - Toast View +struct ToastView: View { + let message: String + let type: ToastType + @Binding var isShowing: Bool + + var body: some View { + VStack { + Spacer() + + if isShowing { + HStack(spacing: 8) { + // ✅ 根据类型显示不同图标 + Image(systemName: type == .error ? "exclamationmark.circle.fill" : type == .success ? "checkmark.circle.fill" : "info.circle.fill") + .font(.system(size: 16)) + .foregroundColor(type == .error ? .red : type == .success ? .green : .blue) + + Text(message) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule() + .fill(type == .error ? Color.red.opacity(0.9) : Color.black.opacity(0.8)) + .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing) + } + } + .padding(.bottom, 100) + .allowsHitTesting(false) + } +} + +// MARK: - Toast Modifier +struct ToastModifier: ViewModifier { + @Binding var isShowing: Bool + let message: String + let type: ToastType + + func body(content: Content) -> some View { + ZStack { + content + + ToastView(message: message, type: type, isShowing: $isShowing) + } + } +} + +extension View { + func toast(message: String, isShowing: Binding, type: ToastType = .info) -> some View { + modifier(ToastModifier(isShowing: isShowing, message: message, type: type)) + } + + // ✅ 便捷方法:成功提示 + func successToast(message: String, isShowing: Binding) -> some View { + toast(message: message, isShowing: isShowing, type: .success) + } + + // ✅ 便捷方法:错误提示 + func errorToast(message: String, isShowing: Binding) -> some View { + toast(message: message, isShowing: isShowing, type: .error) + } +} + diff --git a/ios/WildGrowth/WildGrowth/TokenManager.swift b/ios/WildGrowth/WildGrowth/TokenManager.swift new file mode 100644 index 0000000..ebeb94d --- /dev/null +++ b/ios/WildGrowth/WildGrowth/TokenManager.swift @@ -0,0 +1,45 @@ +import Foundation +import Security + +class TokenManager { + static let shared = TokenManager() + private let tokenKey = "com.wildgrowth.auth.token" + + func saveToken(_ token: String) -> Bool { + let data = token.data(using: .utf8)! + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: tokenKey, + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) // 先删旧的 + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } + + func getToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: tokenKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + if SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data { + return String(data: data, encoding: .utf8) + } + return nil + } + + func deleteToken() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: tokenKey + ] + SecItemDelete(query as CFDictionary) + } +} + + + + diff --git a/ios/WildGrowth/WildGrowth/UserManager.swift b/ios/WildGrowth/WildGrowth/UserManager.swift new file mode 100644 index 0000000..47f25ad --- /dev/null +++ b/ios/WildGrowth/WildGrowth/UserManager.swift @@ -0,0 +1,250 @@ +import SwiftUI +import Kingfisher + +class UserManager: ObservableObject { + static let shared = UserManager() + + @Published var isLoggedIn: Bool = false + // 将 currentUser 的类型从 AuthModels.UserInfo 扩展为包含统计数据的 UserProfileResponse + // 或者我们简单点,只存核心数据,统计数据单独存 + @Published var currentUser: UserInfo? + @Published var studyStats: (time: Int, lessons: Int) = (0, 0) + /// 头像更新后递增,用于电子卡/笔记头像 URL 缓存破坏,实时显示最新头像 + @Published var avatarCacheBust: Int = 0 + + // ✨ 新增:游客状态(只读计算属性) + var isGuest: Bool { + return !isLoggedIn + } + + private let apiClient = APIClient.shared + + init() { + checkLoginStatus() + } + + func checkLoginStatus() { + if TokenManager.shared.getToken() != nil { + isLoggedIn = true + // 启动时静默刷新用户信息 + Task { try? await fetchUserProfile() } + } + } + + // MARK: - F7: Guest Progress Logic + + /// 🎒 [新增] 1. 游客暂存逻辑(存入 UserDefaults) + func saveGuestProgress(nodeId: String, totalStudyTime: Int, completedSlides: Int) { + let data: [String: Any] = [ + "nodeId": nodeId, + "totalStudyTime": totalStudyTime, + "completedSlides": completedSlides, + "timestamp": Date().timeIntervalSince1970 + ] + UserDefaults.standard.set(data, forKey: "pending_guest_record") + print("📦 [Guest] Progress saved locally for node: \(nodeId)") + } + + /// 🔄 [新增] 2. 补单逻辑(登录成功后调用) + func syncGuestProgress() async { + guard let data = UserDefaults.standard.dictionary(forKey: "pending_guest_record"), + let nodeId = data["nodeId"] as? String, + let totalStudyTime = data["totalStudyTime"] as? Int, + let completedSlides = data["completedSlides"] as? Int else { + print("🔄 [Sync] No pending guest record found") + return + } + + print("🔄 [Sync] Found pending guest record for node: \(nodeId), syncing to server...") + + do { + // 调用后端补单接口 + let _ = try await LearningService.shared.completeLesson( + nodeId: nodeId, + totalStudyTime: totalStudyTime, + completedSlides: completedSlides + ) + + print("✅ [Sync] Guest progress synced successfully!") + // 销毁证据(清空背包) + UserDefaults.standard.removeObject(forKey: "pending_guest_record") + } catch { + print("❌ [Sync] Failed to sync guest progress: \(error)") + // 失败则保留数据,下次登录时重试 + } + } + + // MARK: - Login Handler + + /// ✨ [修改] 3. 在登录成功处理中插入补单逻辑 + // ⚠️ 重要:此方法必须在主线程调用,因为会更新 @Published 属性 + @MainActor + func handleLoginSuccess(_ data: LoginData) { + print("👤 [UserManager] 开始处理登录成功(主线程)") + print("🔍 [UserManager] handleLoginSuccess: 登录响应 digitalId = \(data.user.digitalId ?? "nil")") + _ = TokenManager.shared.saveToken(data.token) // ✅ 明确忽略返回值 + self.currentUser = data.user + self.isLoggedIn = true + // 用户状态已随 user 数据保存,无需额外处理 + print("👤 [UserManager] 登录状态已更新: isLoggedIn = \(isLoggedIn)") + print("🔍 [UserManager] handleLoginSuccess: 设置后 currentUser.digitalId = \(self.currentUser?.digitalId ?? "nil")") + + // ✨ 新增:触发补单逻辑(后台线程,不阻塞登录) + Task.detached(priority: .utility) { + await UserManager.shared.syncGuestProgress() + } + + // 拉取用户资料 + Task.detached(priority: .utility) { + do { + try await UserManager.shared.fetchUserProfile() + print("👤 [UserManager] 用户资料拉取成功") + } catch { + print("👤 [UserManager] ⚠️ 用户资料拉取失败(非阻塞): \(error)") + } + } + } + + func logout() { + TokenManager.shared.deleteToken() + self.isLoggedIn = false + self.currentUser = nil + self.studyStats = (0, 0) + // 登出时无需额外清理 + } + + // MARK: - 新增 API 方法 + + // 1. 获取完整用户信息 + @MainActor + func fetchUserProfile() async throws { + print("🔍 [UserManager] fetchUserProfile: 开始调用 API...") + do { + let response: APIResponse = try await apiClient.request( + endpoint: "/api/user/profile", + method: "GET", + requiresAuth: true + ) + + let data = response.data + // ✅ 调试:打印 API 返回的 digitalId + print("🔍 [UserManager] fetchUserProfile: API 返回 digitalId = \(data.digitalId ?? "nil")") + print("🔍 [UserManager] fetchUserProfile: API 返回完整数据 - id=\(data.id), nickname=\(data.nickname), avatar=\(data.avatar ?? "nil")") + print("🔍 [头像调试] fetchUserProfile 返回 avatar: \(data.avatar ?? "nil")") + + // 更新用户基本信息(包含 digitalId) + let previousAvatar = self.currentUser?.avatar + self.currentUser = UserInfo( + id: data.id, + nickname: data.nickname, + phone: data.phone, + avatar: data.avatar, + digitalId: data.digitalId // ✅ 赛博学习证ID + ) + if data.avatar != previousAvatar { avatarCacheBust += 1 } + // ✅ 调试:打印更新后的 digitalId + print("🔍 [UserManager] fetchUserProfile: 更新后 currentUser.digitalId = \(self.currentUser?.digitalId ?? "nil")") + + // 更新统计数据 + self.studyStats = (data.total_study_time, data.completed_lessons) + + print("✅ [UserManager] fetchUserProfile: 完成") + } catch { + print("❌ [UserManager] fetchUserProfile: 失败 - \(error)") + print("❌ [UserManager] fetchUserProfile: 错误详情 - \(error.localizedDescription)") + throw error // 重新抛出错误,让调用者处理 + } + } + + // 2. 更新昵称 + @MainActor + func updateNickname(_ name: String) async throws { + let response: APIResponse = try await apiClient.request( + endpoint: "/api/user/profile", + method: "PUT", + body: ["nickname": name], + requiresAuth: true + ) + // 本地乐观更新(保持现有头像和 digitalId) + if let current = currentUser { + self.currentUser = UserInfo( + id: current.id, + nickname: response.data.nickname, + phone: current.phone, + avatar: response.data.avatar ?? current.avatar, // ✅ 保持现有头像或使用返回的头像 + digitalId: current.digitalId // ✅ 保持 digitalId + ) + } + } + + // ✅ 新增:更新头像 + /// 更新头像 + /// - Parameter imageUrl: 头像URL(从上传接口获取) + @MainActor + func updateAvatar(_ imageUrl: String) async throws { + print("🔍 [头像调试] updateAvatar 入参 imageUrl: \(imageUrl)") + let response: APIResponse = try await apiClient.request( + endpoint: "/api/user/profile", + method: "PUT", + body: ["avatar": imageUrl], + requiresAuth: true + ) + print("🔍 [头像调试] 更新资料接口返回 response.data.avatar: \(response.data.avatar ?? "nil")") + // 本地乐观更新 + if let current = currentUser { + let oldAvatar = current.avatar + self.currentUser = UserInfo( + id: current.id, + nickname: current.nickname, + phone: current.phone, + avatar: response.data.avatar, + digitalId: current.digitalId + ) + print("🔍 [头像调试] 更新后 currentUser.avatar: \(self.currentUser?.avatar ?? "nil")") + avatarCacheBust += 1 + // 清除头像缓存(用与视图一致的完整 URL 作为 key),确保电子卡/笔记里下次加载到最新图 + [oldAvatar, response.data.avatar].compactMap { $0 }.filter { !$0.isEmpty }.forEach { path in + if let url = apiClient.getImageURL(path) { ImageCache.default.removeImage(forKey: url.absoluteString) } + } + } + } + + // 3. 更新设置 (推送) + @MainActor + func updateSettings(pushEnabled: Bool) async throws { + let _: APIResponse = try await apiClient.request( + endpoint: "/api/user/settings", + method: "PUT", + body: ["push_notification": pushEnabled], + requiresAuth: true + ) + } + + // 4. 获取设置 + func fetchSettings() async throws -> Bool { + let response: APIResponse = try await apiClient.request( + endpoint: "/api/user/settings", + method: "GET", + requiresAuth: true + ) + return response.data.push_notification + } + + // 5. 注销账号 (永久删除) + /// - Throws: 网络错误或后端返回的错误信息 + @MainActor + func deleteAccount() async throws { + // ✅ 使用项目中统一的 APIClient + // Endpoint: DELETE /api/user/account + // Response: { "success": true, "message": "..." } -> SimpleResponse + let _: SimpleResponse = try await apiClient.request( + endpoint: "/api/user/account", + method: "DELETE", + requiresAuth: true // 自动添加 Token + ) + + // 成功后,执行本地登出清理逻辑 + self.logout() + } +} + diff --git a/ios/WildGrowth/WildGrowth/UserModels.swift b/ios/WildGrowth/WildGrowth/UserModels.swift new file mode 100644 index 0000000..e638144 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/UserModels.swift @@ -0,0 +1,34 @@ +import Foundation + +// 用户详情响应 (GET /api/user/profile) +struct UserProfileResponse: Codable { + let id: String + let phone: String? + let nickname: String + let avatar: String? + let digitalId: String? // ✅ 赛博学习证ID (Wild ID) + let total_study_time: Int + let completed_lessons: Int + + enum CodingKeys: String, CodingKey { + case id, phone, nickname, avatar + case digitalId = "digital_id" // snake_case -> camelCase + case total_study_time, completed_lessons + } +} + +// 更新用户信息响应 (PUT /api/user/profile) +struct UpdateProfileResponse: Codable { + let id: String + let nickname: String + let avatar: String? // ✅ 支持返回头像URL +} + +// 用户设置响应 (GET/PUT /api/user/settings) +struct UserSettingsResponse: Codable { + let push_notification: Bool +} + + + + diff --git a/ios/WildGrowth/WildGrowth/UserService.swift b/ios/WildGrowth/WildGrowth/UserService.swift new file mode 100644 index 0000000..323af0f --- /dev/null +++ b/ios/WildGrowth/WildGrowth/UserService.swift @@ -0,0 +1,117 @@ +import Foundation +import UIKit + +/// 用户服务 - 处理头像上传等用户相关操作 +class UserService { + static let shared = UserService() + private let apiClient = APIClient.shared + + private init() {} + + /// 上传头像图片 + /// - Parameter image: UIImage 对象 + /// - Returns: 图片URL(相对路径,如 "images/xxx.png") + func uploadAvatar(image: UIImage) async throws -> String { + // 1. 压缩图片(最大 2MB) + guard let imageData = compressImage(image, maxSizeKB: 2048) else { + throw APIError.unknown(NSError(domain: "UserService", code: -1, userInfo: [NSLocalizedDescriptionKey: "图片压缩失败"])) + } + + // 2. 创建 multipart/form-data 请求 + let boundary = UUID().uuidString + let urlString = "\(apiClient.getCurrentServer())/api/upload/image" + guard let url = URL(string: urlString) else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // 3. 添加 Authorization 头 + if let token = TokenManager.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + // 4. 构建 multipart body + var body = Data() + + // 添加 image 字段 + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"image\"; filename=\"avatar.jpg\"\r\n".data(using: .utf8)!) + body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!) + body.append(imageData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + request.httpBody = body + + // 5. 发送请求 + print("🌐 [UserService] 开始上传头像...") + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.unknown(NSError(domain: "UserService", code: -1, userInfo: [NSLocalizedDescriptionKey: "无效的响应"])) + } + + print("🌐 [UserService] 上传响应状态码: \(httpResponse.statusCode)") + + guard (200...299).contains(httpResponse.statusCode) else { + // 尝试解析错误信息 + if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + throw APIError.serverError(errorResponse.error.message) + } + throw APIError.serverError("上传失败 (\(httpResponse.statusCode))") + } + + // 6. 解析响应 + let uploadResponse = try JSONDecoder().decode(APIResponse.self, from: data) + let imageUrl = uploadResponse.data.imageUrl + print("✅ [UserService] 头像上传成功: \(imageUrl)") + print("🔍 [头像调试] 上传接口返回 imageUrl: \(imageUrl)") + return imageUrl + } + + /// 压缩图片 + /// - Parameters: + /// - image: 原始图片 + /// - maxSizeKB: 最大文件大小(KB) + /// - Returns: 压缩后的图片数据 + private func compressImage(_ image: UIImage, maxSizeKB: Int) -> Data? { + var compression: CGFloat = 0.8 + var imageData = image.jpegData(compressionQuality: compression) + + // 如果图片太大,逐步降低质量 + while let data = imageData, data.count > maxSizeKB * 1024 && compression > 0.1 { + compression -= 0.1 + imageData = image.jpegData(compressionQuality: compression) + } + + // 如果降低质量还不够,尝试缩放图片 + if let data = imageData, data.count > maxSizeKB * 1024 { + let maxDimension: CGFloat = 800 // 最大尺寸 + let scale = min(maxDimension / image.size.width, maxDimension / image.size.height) + + if scale < 1.0 { + let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + image.draw(in: CGRect(origin: .zero, size: newSize)) + let scaledImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + if let scaledImage = scaledImage { + imageData = scaledImage.jpegData(compressionQuality: 0.7) + } + } + } + + return imageData + } +} + +// MARK: - 响应模型 + +struct UploadImageResponse: Codable { + let imageUrl: String + let filename: String + let size: Int +} diff --git a/ios/WildGrowth/WildGrowth/Utils/DigitalIDGenerator.swift b/ios/WildGrowth/WildGrowth/Utils/DigitalIDGenerator.swift new file mode 100644 index 0000000..9a03654 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Utils/DigitalIDGenerator.swift @@ -0,0 +1,65 @@ +import Foundation +import CryptoKit + +/// 赛博学习证ID生成器 +/// 从用户UUID生成固定格式的8位数字ID(类似学生证ID) +enum DigitalIDGenerator { + + /// 从用户UUID生成电子证ID + /// - Parameter userId: 用户的UUID字符串 + /// - Returns: 8位数字字符串(如 "89757123") + /// + /// 算法说明: + /// 1. 将UUID字符串转换为稳定的哈希值(使用SHA256) + /// 2. 使用哈希值生成8位数字(范围:10000000 - 99999999) + /// 3. 确保同一个UUID总是生成相同的ID(跨平台、跨版本) + static func generate(from userId: String) -> String { + // 1. 使用SHA256生成稳定的哈希值 + let data = userId.data(using: .utf8) ?? Data() + let hash = SHA256.hash(data: data) + + // 2. 将哈希值的前4个字节转换为整数 + // 使用前4个字节确保有足够的变化范围 + let hashBytes = Array(hash.prefix(4)) + var hashValue: UInt32 = 0 + for (index, byte) in hashBytes.enumerated() { + hashValue |= UInt32(byte) << (index * 8) + } + + // 3. 生成8位数字(范围:10000000 - 99999999) + // 使用取模运算确保在范围内 + let minID: UInt32 = 10000000 + let maxRange: UInt32 = 89999999 // 99999999 - 10000000 = 89999999 + let idValue = minID + (hashValue % maxRange) + + return String(idValue) + } + + /// 获取用户的电子证ID(优先使用数据库字段,否则生成) + /// - Parameters: + /// - digitalId: 数据库中的digitalId(可能为nil) + /// - userId: 用户的主键UUID(用于生成ID) + /// - Returns: 8位数字字符串 + static func getDigitalID(digitalId: String?, userId: String) -> String { + // 如果数据库中有digitalId,直接返回 + if let id = digitalId, !id.isEmpty { + return id + } + + // 否则从userId生成 + return generate(from: userId) + } + + /// 格式化显示ID(添加分隔符,提高可读性) + /// - Parameter id: 8位数字字符串 + /// - Returns: 格式化后的字符串(如 "8975-7123") + static func formatDisplay(_ id: String) -> String { + guard id.count == 8 else { return id } + + // 前4位 + "-" + 后4位 + let index = id.index(id.startIndex, offsetBy: 4) + let prefix = String(id[.. Int { + // 如果没有开始时间,返回0 + guard let startTime = startTime else { + return 0 + } + + // 如果真实进度已经完成,直接返回100 + if realProgress >= 100 { + return 100 + } + + let elapsed = Date().timeIntervalSince(startTime) + let total = Self.totalDuration + + // 按 3 分钟:前面快(前 90 秒到 75%),后面逐渐慢(后 90 秒 75%→99%) + if elapsed < 90 { + let progress = Int((elapsed / 90) * 75) + return min(progress, 99) + } + if elapsed < total { + let progress = 75 + Int(((elapsed - 90) / (total - 90)) * 24) + return min(progress, 99) + } + return 99 + } + + /// 从 ISO 字符串解析日期 + static func parseDate(from isoString: String?) -> Date? { + guard let isoString = isoString else { return nil } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: isoString) ?? formatter.date(from: isoString) + } +} diff --git a/ios/WildGrowth/WildGrowth/VerticalScreenPlayerView.swift b/ios/WildGrowth/WildGrowth/VerticalScreenPlayerView.swift new file mode 100644 index 0000000..81aef3e --- /dev/null +++ b/ios/WildGrowth/WildGrowth/VerticalScreenPlayerView.swift @@ -0,0 +1,819 @@ +import SwiftUI +import UIKit + +// MARK: - ⚙️ 配置模型 +struct HeaderConfig { + let showChapterTitle: Bool + let chapterTitle: String? +} + +// MARK: - 🧬 UI组件:多邻国风格胶囊进度条(支持主题色,与地图/发现页一致) +struct DuolingoProgressBar: View { + var progress: Float + var accentColor: Color = .brandVital + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let height = geo.size.height + + ZStack(alignment: .leading) { + // 1. 底层轨道 + Capsule().fill(accentColor.opacity(0.15)) + + // 2. 进度填充 + if width > 0 { + Capsule() + .fill(accentColor) + .frame(width: max(height, width * CGFloat(progress))) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: progress) + + // 3. 高光 (微调尺寸适配细条) + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(width: max(0, (width * CGFloat(progress)) - 4), height: height * 0.4) + .offset(x: 2, y: -height * 0.2) + .padding(.leading, 2) + } + } + } + .frame(height: 6) + } +} + +// MARK: - 🧱 顶部导航栏(支持主题色,与地图/发现页一致) +struct CourseProgressNavBar: View { + let progress: Float + let onBack: () -> Void + var accentColor: Color = .brandVital + /// 完结页不需要进度条,传 false 时仅保留占位以维持布局稳定 + var showProgressBar: Bool = true + + var body: some View { + HStack(spacing: 12) { + Button(action: onBack) { + Image(systemName: "arrow.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.inkPrimary) + .frame(width: 44, height: 44) + } + + if showProgressBar { + DuolingoProgressBar(progress: progress, accentColor: accentColor) + .frame(maxWidth: 280) + .padding(.vertical, 8) + } else { + // 完结页:不显示进度条,用透明占位保持高度一致 + Color.clear + .frame(maxWidth: 280) + .padding(.vertical, 8) + } + + Spacer() + } + .padding(.leading, 4) + .frame(height: 50) + .background(Color.bgWhite.opacity(0.98)) + .overlay( + Rectangle().fill(Color.inkPrimary.opacity(0.03)).frame(height: 0.5), + alignment: .bottom + ) + } +} + +// MARK: - 📱 竖屏播放器主视图 (Unified Pagination & Zero Impact) +struct VerticalScreenPlayerView: View { + // 1. 基础参数 + let courseId: String + let initialNodeId: String + let initialScrollIndex: Int? + + // 2. 兼容参数 (完整保留,零影响) + var navigationPath: Binding? + var isLastNode: Bool = false + var courseTitle: String? = nil + + // 3. 内部状态 + @State private var currentNodeId: String + @State private var allItems: [PlayerItem] = [] // UI 专用数据源 + @State private var isLoading = true + @State private var mapData: MapData? + @State private var loadError: String? + + // 4. 辅助 UI 状态 + @State private var showToast = false + @State private var toastMessage = "" + + @Environment(\.dismiss) var dismiss + + // 内部枚举:统一管理课程页与完结页 + enum PlayerItem: Identifiable, Equatable { + case lesson(MapNode) + case completion + + var id: String { + switch self { + case .lesson(let node): return node.id + case .completion: return "COMPLETION_PAGE" + } + } + } + + // ✅ Init 保持 6 参不变,达成零影响 + init(courseId: String, nodeId: String, initialScrollIndex: Int? = nil, navigationPath: Binding? = nil, isLastNode: Bool = false, courseTitle: String? = nil) { + self.courseId = courseId + self.initialNodeId = nodeId + self.initialScrollIndex = initialScrollIndex + self.navigationPath = navigationPath + self.isLastNode = isLastNode + self.courseTitle = courseTitle + self._currentNodeId = State(initialValue: nodeId) + } + + var body: some View { + ZStack(alignment: .top) { + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 0) { + // 导航栏 (常驻;完结页不显示进度条,仅保留占位;进度条颜色与地图/发现页一致) + CourseProgressNavBar( + progress: currentPositionProgress, + onBack: handleBack, + accentColor: playerAccentColor, + showProgressBar: currentNodeId != "COMPLETION_PAGE" + ) + .transition(.opacity) + + if isLoading { + LessonSkeletonView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = loadError { + // 错误态 (保留原有 UI) + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.inkSecondary) + Text("加载失败") + .font(.headline) + .foregroundColor(.inkPrimary) + Text(error) + .font(.caption) + .foregroundColor(.inkSecondary) + .multilineTextAlignment(.center) + Button("重试") { + loadError = nil + loadMapData() + } + .buttonStyle(.bordered) + .tint(.brandVital) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else if !allItems.isEmpty { + // MARK: 核心 - 统一分页视图 + TabView(selection: $currentNodeId) { + ForEach(allItems) { item in + switch item { + case .lesson(let node): + LessonPageView( + courseId: courseId, + nodeId: node.id, + currentGlobalNodeId: $currentNodeId, + initialScrollIndex: node.id == initialNodeId ? initialScrollIndex : nil, + headerConfig: HeaderConfig( + showChapterTitle: isFirstNodeInChapter(nodeId: node.id), + chapterTitle: getChapterTitle(for: node.id) + ), + courseTitle: self.courseTitle ?? mapData?.courseTitle, + accentColor: playerAccentColor, + navigationPath: navigationPath + ) + .tag(node.id) + + case .completion: + CompletionView( + courseId: courseId, + courseTitle: self.courseTitle ?? mapData?.courseTitle, + navigationPath: navigationPath + ) + .tag("COMPLETION_PAGE") + } + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea(.container, edges: .bottom) + .animation(.easeInOut, value: currentNodeId == "COMPLETION_PAGE") + .onChange(of: currentNodeId) { _, _ in + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + } + } else { + // 空状态 (保留原有 UI) + VStack(spacing: 16) { + Image(systemName: "book.closed") + .font(.system(size: 40)) + .foregroundColor(.inkSecondary) + Text("暂无内容") + .font(.headline) + .foregroundColor(.inkPrimary) + Text("该课程还没有可用的学习内容") + .font(.caption) + .foregroundColor(.inkSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + } + } + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { + AnalyticsManager.shared.track("lesson_start", properties: ["course_id": courseId, "node_id": initialNodeId]) + hideTabBar() + loadMapData() + } + .onDisappear { + showTabBar() + } + .toast(message: toastMessage, isShowing: $showToast) + } + + // MARK: - Logic + + func loadMapData() { + isLoading = true + loadError = nil + Task { + do { + let data = try await CourseService.shared.getCourseMap(courseId: courseId) + await MainActor.run { + self.mapData = data + + let realNodes = data.chapters + .flatMap { $0.nodes } + .filter { $0.status != .locked } + + var items = realNodes.map { PlayerItem.lesson($0) } + if !items.isEmpty { + items.append(.completion) + } + self.allItems = items + self.isLoading = false + + if !items.contains(where: { $0.id == initialNodeId }) { + if let first = items.first { + self.currentNodeId = first.id + } + } + } + } catch { + #if DEBUG + print("❌ [Player] 加载地图失败: \(error)") + #endif + await MainActor.run { + self.isLoading = false + self.loadError = error.localizedDescription + showToastMessage("加载失败") + } + } + } + } + + /// 播放器主题色(与地图/发现页一致:theme_color 优先,否则 courseId 哈希从 6 色池选) + private var playerAccentColor: Color { + if let hex = mapData?.themeColor, !hex.isEmpty { + return Color(hex: hex) + } + var hash = 0 + for char in courseId.utf8 { + hash = (hash &* 31) &+ Int(char) + } + let colors: [Color] = [ + Color(hex: "2266FF"), Color(hex: "58CC02"), Color(hex: "8A4FFF"), + Color(hex: "FF4B4B"), Color(hex: "FFC800"), Color(hex: "FF9600") + ] + return colors[abs(hash) % colors.count] + } + + /// 进度条:加载中或未命中时返回 0,进入后从起点滑到当前进度;完结页返回 1.0 + private var currentPositionProgress: Float { + let lessonItems = allItems.filter { if case .lesson = $0 { return true }; return false } + if currentNodeId == "COMPLETION_PAGE" { return 1.0 } + guard !lessonItems.isEmpty, + let index = lessonItems.firstIndex(where: { $0.id == currentNodeId }) else { return 0 } + return Float(index + 1) / Float(lessonItems.count) + } + + func handleBack() { + if let path = navigationPath, !path.wrappedValue.isEmpty { + path.wrappedValue.removeLast() + } else { + dismiss() + } + } + + func hideTabBar() { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let tabBarController = window.rootViewController as? UITabBarController { + tabBarController.tabBar.isHidden = true + } + } + + func showTabBar() { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let tabBarController = window.rootViewController as? UITabBarController { + tabBarController.tabBar.isHidden = false + } + } + + func showToastMessage(_ message: String) { + toastMessage = message + showToast = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { showToast = false } + } + + private func isFirstNodeInChapter(nodeId: String) -> Bool { + guard let map = mapData else { return false } + for chapter in map.chapters { + let validNodes = chapter.nodes + .filter { $0.status != .locked } + .sorted { $0.order < $1.order } + if let first = validNodes.first, first.id == nodeId { + return true + } + } + return false + } + + private func getChapterTitle(for nodeId: String) -> String? { + mapData?.chapters.first(where: { $0.nodes.contains(where: { $0.id == nodeId }) })?.title + } +} + +// MARK: - 📄 单页课程视图 +struct LessonPageView: View { + let courseId: String + let nodeId: String + @Binding var currentGlobalNodeId: String + let initialScrollIndex: Int? + let headerConfig: HeaderConfig + var courseTitle: String? + var accentColor: Color? = nil + var navigationPath: Binding? + + @State private var lessonDetail: LessonDetail? + @State private var isLoading = true + @State private var errorMessage: String? + + @State private var notes: [Note] = [] + @State private var currentProgress: Float = 0.0 + @State private var previousCompletionRate: Int? + @State private var studyStartTime = Date() + @State private var hasCompleted = false + + @State private var showInputSheet = false + @State private var showBottomSheet = false + @State private var showLoginSheet = false + @State private var showToast = false + @State private var toastMessage = "" + + @State private var pendingNoteRange: NSRange? + @State private var pendingSelectedText = "" + @State private var inputInitialContent = "" + @State private var editingNoteId: String? + @State private var selectedNotes: [Note] = [] + @State private var currentQuotedText = "" + + private var isActive: Bool { currentGlobalNodeId == nodeId } + + var body: some View { + ZStack { + Color.bgPaper + + if let detail = lessonDetail { + ArticleRichTextViewRepresentable( + lessonDetail: detail, + notes: notes, + initialScrollIndex: initialScrollIndex, + headerConfig: headerConfig, + accentColor: accentColor, + onHighlight: handleHighlight, + onThought: handleThought, + onNoteTap: handleNoteTap, + onScrollProgress: { progress in + guard isActive else { return } + updateProgress(progress) + }, + onCopySuccess: { showToastMessage("已复制") } + ) + } else if isLoading { + LessonSkeletonView() + } else if let error = errorMessage { + LessonErrorView(message: error, onRetry: { loadContent(force: true) }) + } + } + .onAppear { + if lessonDetail == nil { loadContent() } + if isActive { studyStartTime = Date() } + // 打开页面即视为完成:调用开始学习;完成页由最后一节再左滑进入(TabView 多一页占位) + if UserManager.shared.isLoggedIn { + Task { try? await LearningService.shared.startLesson(nodeId: nodeId) } + } + } + .onChange(of: currentGlobalNodeId) { _, newId in + if newId == nodeId { + studyStartTime = Date() + } else { + if currentProgress >= 1.0 { completeLesson() } + } + } + .onDisappear { + if isActive && currentProgress >= 1.0 { completeLesson() } + } + .sheet(isPresented: $showInputSheet) { + NoteInputView( + isPresented: $showInputSheet, + selectedText: pendingSelectedText, + initialContent: inputInitialContent, + onSave: handleSaveNoteContent + ) + } + .sheet(isPresented: $showBottomSheet) { + NoteBottomSheetView( + quotedText: currentQuotedText, + notes: selectedNotes, + currentUserAvatar: UserManager.shared.currentUser?.avatar, + currentUserId: UserManager.shared.currentUser?.id, + avatarCacheBust: UserManager.shared.avatarCacheBust, + onAddThought: handleAddThought, + onDeleteHighlight: handleDeleteHighlight, + onEditNote: handleEditNote, + onDeleteNote: handleDeleteNote + ) + .presentationDetents([.fraction(0.75), .large]) + .modifier(iPadSheetAdaptationModifier()) + } + .sheet(isPresented: $showLoginSheet) { + LoginView( + isLoggedIn: Binding( + get: { UserManager.shared.isLoggedIn }, + set: { UserManager.shared.isLoggedIn = $0 } + ), + onClose: { showLoginSheet = false } + ) + } + .toast(message: toastMessage, isShowing: $showToast) + } + + // MARK: - Data & Logic + + func loadContent(force: Bool = false) { + if lessonDetail != nil && !force { return } + isLoading = true + errorMessage = nil + Task { + do { + let detail = try await CourseService.shared.getLessonDetail(nodeId: nodeId) + var fetchedNotes: [Note] = [] + if UserManager.shared.isLoggedIn { + let noteResult = try await NoteService.shared.getNotes(nodeId: nodeId) + fetchedNotes = noteResult.notes + } + await MainActor.run { + self.lessonDetail = detail + self.notes = fetchedNotes + self.isLoading = false + self.previousCompletionRate = Int((detail.lastProgress ?? 0.0) * 100) + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + } + + /// 仅更新本地进度(用于判断是否调用 completeLesson 解锁下一节);不再上报滚动位置到后端(打开即视为完成)。 + func updateProgress(_ progress: Float) { + let normalizedProgress: Float + if progress >= 1.0 { + normalizedProgress = 1.0 + } else { + normalizedProgress = progress >= 0.95 ? 1.0 : progress + } + if abs(normalizedProgress - currentProgress) > 0.01 { + currentProgress = normalizedProgress + } + } + + func completeLesson() { + guard !hasCompleted else { return } + Task { + let studyTime = max(Int(Date().timeIntervalSince(studyStartTime)), 1) + let totalBlocks = lessonDetail?.blocks.count ?? 0 + do { + _ = try await LearningService.shared.completeLesson( + nodeId: nodeId, + totalStudyTime: studyTime, + completedSlides: totalBlocks + ) + AnalyticsManager.shared.track("lesson_complete", properties: [ + "course_id": courseId, + "node_id": nodeId, + "study_time": studyTime + ]) + await MainActor.run { self.hasCompleted = true } + } catch { + #if DEBUG + print("❌ [Player] 完课上报失败: \(error)") + #endif + } + } + } + + // MARK: - Note Logic (Consistency Fix) + + private func buildFullContentForLogic(detail: LessonDetail) -> NSAttributedString { + let finalStr = NSMutableAttributedString() + + if headerConfig.showChapterTitle, let cTitle = headerConfig.chapterTitle { + let chapterAttr: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 15, weight: .medium), + .foregroundColor: UIColor.inkSecondary, + .paragraphStyle: { + let p = NSMutableParagraphStyle() + p.paragraphSpacing = 8 + return p + }() + ] + finalStr.append(NSAttributedString(string: "\(cTitle)\n", attributes: chapterAttr)) + } + + let titleAttr: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 28, weight: .bold), + .foregroundColor: UIColor.inkPrimary, + .paragraphStyle: { + let p = NSMutableParagraphStyle() + p.paragraphSpacing = 16 + p.lineSpacing = 4 + return p + }() + ] + finalStr.append(NSAttributedString(string: "\(detail.title)\n", attributes: titleAttr)) + + let bodyContent = ContentBlockBuilder.buildAttributedString(from: detail.blocks) + finalStr.append(bodyContent) + + return finalStr + } + + func handleSaveNoteContent(_ content: String) { + if let noteId = editingNoteId { + updateExistingNote(id: noteId, content: content) + } else { + createNewNote(content: content) + } + } + + func handleHighlight(range: NSRange) { + createNoteCore(range: range, type: .highlight, content: nil, quotedText: nil) + } + + func handleThought(range: NSRange) { + prepareForThought(range: range) + } + + func prepareForThought(range: NSRange) { + guard let detail = lessonDetail, UserManager.shared.isLoggedIn else { + showToastMessage("请先登录") + return + } + editingNoteId = nil + inputInitialContent = "" + + let fullContent = buildFullContentForLogic(detail: detail) + guard range.location >= 0, range.location + range.length <= fullContent.length else { return } + + pendingNoteRange = range + pendingSelectedText = fullContent.attributedSubstring(from: range).string + showInputSheet = true + } + + func createNewNote(content: String) { + guard let range = pendingNoteRange else { return } + createNoteCore(range: range, type: .thought, content: content, quotedText: pendingSelectedText) + pendingNoteRange = nil + pendingSelectedText = "" + } + + func createNoteCore(range: NSRange, type: NoteType, content: String?, quotedText: String?) { + guard let detail = lessonDetail else { return } + guard let currentUser = UserManager.shared.currentUser else { + showToastMessage("请先登录") + return + } + + var finalQuotedText = quotedText + if finalQuotedText == nil { + let fullContent = buildFullContentForLogic(detail: detail) + if range.location >= 0 && range.location + range.length <= fullContent.length { + finalQuotedText = fullContent.attributedSubstring(from: range).string + } else { + showToastMessage("范围无效") + return + } + } + guard let text = finalQuotedText, !text.isEmpty else { return } + + let tempId = UUID().uuidString + let tempNote = Note( + id: tempId, userId: currentUser.id, userName: currentUser.nickname, + notebookId: nil, parentId: nil, order: 0, level: 0, + courseId: courseId, nodeId: nodeId, + startIndex: range.location, length: range.length, + type: type, content: content, quotedText: text, style: nil, + createdAt: Date(), updatedAt: Date(), courseTitle: nil, nodeTitle: nil + ) + + withAnimation { notes.append(tempNote) } + + Task { + do { + let request = CreateNoteRequest( + notebookId: nil, parentId: nil, order: nil, level: nil, + courseId: courseId, nodeId: nodeId, + startIndex: range.location, length: range.length, + type: type.rawValue, quotedText: text, content: content, style: nil + ) + let createdNote = try await NoteService.shared.createNote(request) + AnalyticsManager.shared.track("note_create", properties: [ + "course_id": courseId, + "node_id": nodeId, + "note_type": type.rawValue + ]) + await MainActor.run { + if let index = notes.firstIndex(where: { $0.id == tempId }) { + notes[index] = createdNote + } + } + } catch { + #if DEBUG + print("❌ [Player] 创建笔记API失败: \(error)") + #endif + await MainActor.run { + notes.removeAll(where: { $0.id == tempId }) + showToastMessage("保存失败") + } + } + } + } + + func updateExistingNote(id: String, content: String) { + guard let index = notes.firstIndex(where: { $0.id == id }) else { return } + + let oldNote = notes[index] + let updatedNote = createUpdatedNoteCopy(original: oldNote, newContent: content) + notes[index] = updatedNote + + Task { + do { + let request = UpdateNoteRequest(parentId: nil, order: nil, level: nil, content: content, quotedText: nil, startIndex: nil, length: nil, style: nil) + let serverNote = try await NoteService.shared.updateNote(noteId: id, request: request) + await MainActor.run { + if let idx = notes.firstIndex(where: { $0.id == id }) { + notes[idx] = serverNote + } + showToastMessage("修改已保存") + } + } catch { + await MainActor.run { + loadContent(force: true) + showToastMessage("更新失败") + } + } + } + editingNoteId = nil + } + + private func createUpdatedNoteCopy(original: Note, newContent: String) -> Note { + return Note( + id: original.id, userId: original.userId, userName: original.userName, + notebookId: original.notebookId, parentId: original.parentId, + order: original.order, level: original.level, + courseId: original.courseId, nodeId: original.nodeId, + startIndex: original.startIndex, length: original.length, + type: original.type, content: newContent, quotedText: original.quotedText, + style: original.style, createdAt: original.createdAt, updatedAt: Date(), + courseTitle: original.courseTitle, nodeTitle: original.nodeTitle + ) + } + + // ... Note Interactions + func handleNoteTap(note: Note) { + guard let startIndex = note.startIndex, let length = note.length else { return } + let targetRange = NSRange(location: startIndex, length: length) + selectedNotes = notes.filter { + guard let s = $0.startIndex, let l = $0.length else { return false } + return NSIntersectionRange(NSRange(location: s, length: l), targetRange).length > 0 + } + currentQuotedText = selectedNotes.first?.quotedText ?? note.quotedText ?? "" + showBottomSheet = true + } + + func handleAddThought() { + guard let firstNote = selectedNotes.first else { return } + showBottomSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if let start = firstNote.startIndex, let len = firstNote.length { + prepareForThought(range: NSRange(location: start, length: len)) + } + } + } + + func handleDeleteHighlight() { + let notesToDelete = selectedNotes.filter { $0.type == .highlight && !$0.isSystemNote } + if notesToDelete.isEmpty { + showToastMessage("无划线") + return + } + deleteNotes(notesToDelete) + showBottomSheet = false + } + + func handleEditNote(_ note: Note) { + showBottomSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + guard let start = note.startIndex, let length = note.length, let quote = note.quotedText else { return } + pendingNoteRange = NSRange(location: start, length: length) + pendingSelectedText = quote + inputInitialContent = note.content ?? "" + editingNoteId = note.id + showInputSheet = true + } + } + + func handleDeleteNote(_ note: Note) { + deleteNotes([note]) { success in + if success { + selectedNotes.removeAll { $0.id == note.id } + showBottomSheet = false + } + } + } + + func deleteNotes(_ notesToDelete: [Note], completion: ((Bool) -> Void)? = nil) { + let noteIds = notesToDelete.map { $0.id } + withAnimation { notes.removeAll { noteIds.contains($0.id) } } + Task { + var failureCount = 0 + for note in notesToDelete { + do { try await NoteService.shared.deleteNote(noteId: note.id) } + catch { failureCount += 1 } + } + await MainActor.run { + if failureCount > 0 { + loadContent(force: true) + showToastMessage("\(failureCount) 条笔记删除失败") + } + completion?(failureCount == 0) + } + } + } + + func showToastMessage(_ message: String) { + toastMessage = message + showToast = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { showToast = false } + } +} + +struct LessonSkeletonView: View { + var body: some View { + VStack(alignment: .leading, spacing: 20) { + RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.1)).frame(height: 32).padding(.trailing, 60) + ForEach(0..<6) { _ in + RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.08)).frame(height: 16) + } + Spacer() + } + .padding(24).padding(.top, 40).background(Color.bgPaper) + } +} + +struct LessonErrorView: View { + let message: String + let onRetry: () -> Void + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundColor(.inkSecondary) + Text("加载失败").font(.headline) + Text(message).font(.caption).foregroundColor(.gray) + Button("重试", action: onRetry).buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.bgPaper) + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/AboutView.swift b/ios/WildGrowth/WildGrowth/Views/AboutView.swift new file mode 100644 index 0000000..89586b1 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/AboutView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct AboutView: View { + @Environment(\.dismiss) var dismiss + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + } + + private var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + } + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 32) { + // App 图标 + 名称 + VStack(spacing: 12) { + Image("AppIcon-Display") + .resizable() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .shadow(color: .black.opacity(0.1), radius: 8, y: 4) + .overlay( + // 如果没有 AppIcon-Display 资源,用文字替代 + RoundedRectangle(cornerRadius: 18) + .fill(Color.clear) + ) + + Text("电子成长") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.primary) + + Text("版本 1.0") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + .padding(.top, 40) + + // 简介 + Text("AI 驱动的知识学习应用") + .font(.system(size: 15)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + + // 链接区域 + VStack(spacing: 0) { + aboutLink( + title: "用户服务协议", + icon: "doc.text", + url: "\(APIClient.shared.baseURL)/user-agreement.html" + ) + + Divider().padding(.leading, 52) + + aboutLink( + title: "隐私政策", + icon: "lock.shield", + url: "\(APIClient.shared.baseURL)/privacy-policy.html" + ) + } + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.04), radius: 6, y: 2) + .padding(.horizontal, 20) + + // 联系方式 + VStack(spacing: 8) { + Text("联系我们") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + Text("noahbreak859@gmail.com") + .font(.system(size: 14)) + .foregroundColor(.primary) + } + .padding(.top, 8) + + Spacer() + } + } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .navigationTitle("关于电子成长") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("完成") { dismiss() } + .font(.system(size: 16, weight: .medium)) + } + } + } + .navigationViewStyle(.stack) + } + + @ViewBuilder + private func aboutLink(title: String, icon: String, url: String) -> some View { + if let linkURL = URL(string: url) { + Link(destination: linkURL) { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundColor(.brandVital) + .frame(width: 28) + + Text(title) + .font(.system(size: 16)) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray.opacity(0.5)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + } + } +} + +#Preview { + AboutView() +} diff --git a/ios/WildGrowth/WildGrowth/Views/CompletionView.swift b/ios/WildGrowth/WildGrowth/Views/CompletionView.swift new file mode 100644 index 0000000..429ef48 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/CompletionView.swift @@ -0,0 +1,276 @@ +import SwiftUI +import UIKit + +// MARK: - 🏆 课程完结页 (Final Polish Edition) +// 视觉:正灰色按钮 + 黄金时间加载 + 纯净样式 +struct CompletionView: View { + // MARK: - 1. Init Parameters + let courseId: String + let courseTitle: String? + var navigationPath: Binding? + + // MARK: - 2. Dependencies + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var navStore: NavigationStore + @ObservedObject private var userManager = UserManager.shared + + // MARK: - 3. Local State + enum ViewState { + case idle // 待机 + case loading // 加载中 + case filling // 墨水扩散 + case completed // 显示数据 + } + @State private var viewState: ViewState = .idle + @State private var inkScale: CGFloat = 0.0 + + // 滚动数字状态 + @State private var displayLessons: Double = 0 + @State private var displayMinutes: Double = 0 + + // 旋转状态 + @State private var isSpinning = false + + // MARK: - 4. Color System + // ✅ 与播放器页保持一致,使用设计系统 bgPaper (#FBFBFD) + private var pageBackground: Color { Color.bgPaper } + + // ✅ 修正:扎实的“正灰色”,不发白,不发蓝 + // RGB: 235, 235, 237 (#EBEBED) + private let solidGray = Color(red: 0.92, green: 0.92, blue: 0.93) + + // 深灰文字色 + private let darkText = Color.black.opacity(0.65) + + // 品牌渐变 (粉 -> 紫) + private let brandGradient = LinearGradient( + colors: [ + Color(red: 1.0, green: 0.35, blue: 0.80), // Hot Pink + Color(red: 0.58, green: 0.20, blue: 0.90) // Deep Purple + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + var body: some View { + ZStack { + pageBackground.ignoresSafeArea() + + VStack(spacing: 0) { + + Spacer() + + // 1. 标题 (无动效) + Text(viewState == .completed ? "你做到了" : "全部完成") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.primary) + .padding(.bottom, 24) + + // 2. 核心屏幕卡片 + RefinedCard + + Spacer() + + // 3. 底部次按钮 (纯净灰色) + Button { + handleBackToContent() + } label: { + Text("回到我的课程") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(darkText) + .frame(width: 200, height: 48) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(solidGray) + ) + } + .buttonStyle(ScaleButtonStyle()) // ✅ 按压缩放反馈,颜色不变“脏感” + .padding(.bottom, 50) + } + } + .toolbar(.hidden, for: .navigationBar) + .onAppear { checkSystemStatus() } + } + + // MARK: - 5. Components + + private var RefinedCard: some View { + ZStack { + // 底色 + Color.white + + // 墨水层 + ZStack { + brandGradient + } + .mask( + Circle() + .fill(Color.black) + .scaleEffect(inkScale) + .frame(width: 300, height: 300) + ) + + // 内容层 + ZStack { + if viewState == .idle { + IdleView + } else if viewState == .loading { + LoadingView + } else if viewState == .completed || viewState == .filling { + ActiveDashboard + .opacity(viewState == .completed ? 1 : 0) + .animation(.easeOut(duration: 0.5).delay(0.2), value: viewState) + } + } + .padding(24) + } + .frame(width: 320, height: 520) + .clipShape(RoundedRectangle(cornerRadius: 36, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 36, style: .continuous) + .stroke(Color.gray.opacity(0.15), lineWidth: 0.5) + ) + .shadow(color: Color.black.opacity(0.08), radius: 30, y: 15) + } + + // --- 状态 A: 待机 --- + private var IdleView: some View { + Button(action: startLoadingProcess) { + VStack(spacing: 30) { + Spacer() + + Image(systemName: "icloud.and.arrow.up.fill") + .font(.system(size: 220)) + .foregroundStyle(brandGradient) + .shadow(color: Color.purple.opacity(0.25), radius: 20, y: 10) + + Text("上传学习数据") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(brandGradient) + + Spacer() + } + } + .buttonStyle(ScaleButtonStyle()) // 复用项目已有实现 + } + + // --- 状态 B: 加载中 --- + private var LoadingView: some View { + ZStack { + Circle() + .trim(from: 0, to: 0.7) + .stroke( + brandGradient, + style: StrokeStyle(lineWidth: 6, lineCap: .round, dash: [10, 15]) + ) + .frame(width: 80, height: 80) + .rotationEffect(.degrees(isSpinning ? 360 : 0)) + .onAppear { + isSpinning = false + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + isSpinning = true + } + } + } + } + + // --- 状态 C: 激活 (纯净数字丰碑) --- + private var ActiveDashboard: some View { + VStack(spacing: 0) { + // 上方弹簧,负责把内容压在中间 + Spacer() + + // 1. 巨型数字 + RollingNumberText(value: displayLessons) + .font(.system(size: 110, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + .minimumScaleFactor(0.5) + .lineLimit(1) + .frame(height: 120) + .shadow(color: Color.black.opacity(0.1), radius: 4, y: 4) + + // 2. 说明文案 (增加了顶部间距,让布局更舒展) + Text("共完成小节") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.white.opacity(0.95)) // 稍微提亮一点 + .padding(.top, 20) // 从 10 改为 20,增加呼吸感 + + // 下方弹簧,与上方 Spacer 配合实现垂直居中 + Spacer() + } + // 视觉居中微调:人眼的"视觉中心"比"几何中心"略高 + .offset(y: -5) + } + + // MARK: - 6. Logic + + private var storageKey: String { "has_revealed_course_\(courseId)" } + + private func checkSystemStatus() { + if UserDefaults.standard.bool(forKey: storageKey) { + self.displayLessons = Double(userManager.studyStats.lessons) + self.displayMinutes = Double(userManager.studyStats.time) + self.viewState = .completed + self.inkScale = 3.5 + } + } + + private func startLoadingProcess() { + guard viewState == .idle else { return } + + let impact = UIImpactFeedbackGenerator(style: .medium) + impact.impactOccurred() + + viewState = .loading + + Task { + // ✅ 黄金时间 0.8秒 + try? await Task.sleep(nanoseconds: 800_000_000) + + if !userManager.isGuest { try? await userManager.fetchUserProfile() } + + await MainActor.run { + withAnimation(.easeInOut(duration: 0.1)) { viewState = .filling } + + let success = UINotificationFeedbackGenerator() + success.notificationOccurred(.success) + + withAnimation(.easeInOut(duration: 0.6)) { self.inkScale = 3.5 } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + self.viewState = .completed + + let targetLessons = Double(max(userManager.studyStats.lessons, 1)) + let targetMinutes = Double(userManager.isGuest ? 0 : userManager.studyStats.time) + + withAnimation(.linear(duration: 1.0)) { + self.displayLessons = targetLessons + self.displayMinutes = targetMinutes + } + UserDefaults.standard.set(true, forKey: storageKey) + } + } + } + } + + private func handleBackToContent() { + navStore.switchToGrowthTab() + if let path = navigationPath { + path.wrappedValue = NavigationPath() + } else { + dismiss() + } + } +} + +// 滚动数字 +struct RollingNumberText: View, Animatable { + var value: Double + var animatableData: Double { + get { value } + set { value = newValue } + } + var body: some View { + Text("\(Int(value))") + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/CreateCourseInputView.swift b/ios/WildGrowth/WildGrowth/Views/CreateCourseInputView.swift new file mode 100644 index 0000000..58d5d2e --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/CreateCourseInputView.swift @@ -0,0 +1,371 @@ +import SwiftUI +import UIKit // ✅ 已修复:导入 UIKit +import UniformTypeIdentifiers + +// ⚠️ 废弃提示:此 View 已被 CreationHubView 取代 +// 保留此文件仅用于向后兼容,新功能请使用 CreationHubView +// TODO: 在确认无依赖后删除此文件 + +// ⚠️ CreateCourseRoute 枚举已移至 CreateCourseRoute.swift,避免重复定义 + +struct CreateCourseInputView: View { + // MARK: - State + @State private var sourceText: String = "" + @State private var isLoading: Bool = false + @State private var showError: Bool = false + @State private var errorMessage: String = "" + @State private var showDocumentPicker: Bool = false + @State private var isParsingDocument: Bool = false + + // 文档解析状态(类似 ChatGPT 文件上传) + @State private var parsedDocumentText: String? = nil + @State private var parsedFileName: String? = nil + + // ✅ 思考流文本(用于 GenerationProgressView) + @State private var thinkingFlowText: String = "" + + // ✅ 使用 NavigationPath 管理所有跳转 + @State private var path = NavigationPath() + + // ✅ 新增:接收来自 GrowthView 的回调 + var onCourseCompleted: ((String) -> Void)? + + // 书籍解析/文本框上限(与后端 CHUNK_SIZE 一致,便于后续调整) + private let maxCharCount = 150000 + + @Environment(\.dismiss) var dismiss + + @ViewBuilder + var body: some View { + // ✅ 绑定 path 到 NavigationStack + NavigationStack(path: $path) { + contentView + .navigationTitle("创建新课程") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("取消") { dismiss() } + .foregroundColor(.inkSecondary) + } + } + .fileImporter( + isPresented: $showDocumentPicker, + allowedContentTypes: [ + .pdf, + UTType(filenameExtension: "docx") ?? .data, + UTType(filenameExtension: "doc") ?? .data, + UTType(filenameExtension: "epub") ?? .data + ], + allowsMultipleSelection: false + ) { result in + handleDocumentSelection(result: result) + } + .navigationDestination(for: CreateCourseRoute.self) { route in + switch route { + case .progress(let taskId, let courseId, let flowText): + GenerationProgressView( + taskId: taskId, + courseId: courseId, + thinkingFlowText: flowText, + joinedAt: nil, // ✅ 创建中可能还没有 joinedAt,使用 nil(会在 GenerationProgressView 中处理) + onCompletion: { completedCourseId in + dismiss() + onCourseCompleted?(completedCourseId) + }, + onCloseTapped: { dismiss() } + ) + case .personaSelection: + EmptyView() + } + } + .alert("创建失败", isPresented: $showError) { + Button("确定", role: .cancel) { } + } message: { + Text(errorMessage) + } + } + } + + @ViewBuilder + private var contentView: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 16) { + headerSection + textInputSection + uploadDocumentButton + parsedDocumentView + submitButton + } + .padding(.horizontal, 20) + } + } + + @ViewBuilder + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("输入课程素材") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.inkPrimary) + + Text("粘贴文章、笔记或会议记录,AI 将为您生成结构化课程。") + .font(.system(size: 14)) + .foregroundColor(.inkSecondary) + } + .padding(.top, 10) + } + + @ViewBuilder + private var textInputSection: some View { + VStack(spacing: 0) { + TextEditor(text: $sourceText) + .font(.body) + .foregroundColor(.inkPrimary) + .padding(16) + .scrollContentBackground(.hidden) + .background(Color.bgWhite) + .cornerRadius(16, corners: [.topLeft, .topRight]) + .frame(maxHeight: .infinity) + .onChange(of: sourceText) { _, newValue in + if newValue.count > maxCharCount { + sourceText = String(newValue.prefix(maxCharCount)) + } + } + + HStack { + Spacer() + Text("\(sourceText.count) / \(maxCharCount)") + .font(.caption) + .foregroundColor(sourceText.count > maxCharCount ? .red : .inkSecondary) + .padding(12) + } + .background(Color.bgWhite) + .cornerRadius(16, corners: [.bottomLeft, .bottomRight]) + } + .shadow(color: Color.black.opacity(0.05), radius: 8, x: 0, y: 2) + } + + @ViewBuilder + private var uploadDocumentButton: some View { + Button { + showDocumentPicker = true + } label: { + HStack { + if isParsingDocument { + ProgressView().tint(.inkPrimary) + .padding(.trailing, 4) + } else { + Image(systemName: "doc.fill") + .font(.system(size: 16)) + } + Text(isParsingDocument ? "正在解析文档..." : "上传文档(PDF/Word/EPUB)") + .font(.system(size: 16, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding() + .background(isParsingDocument ? Color.inkSecondary.opacity(0.3) : Color.bgWhite) + .foregroundColor(isParsingDocument ? .inkSecondary : .inkPrimary) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.inkSecondary.opacity(0.3), lineWidth: 1) + ) + } + .disabled(isParsingDocument) + } + + @ViewBuilder + private var parsedDocumentView: some View { + if let fileName = parsedFileName { + HStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.system(size: 16)) + .foregroundColor(.brandVital) + + VStack(alignment: .leading, spacing: 4) { + Text(fileName) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.inkPrimary) + Text("解析完成,可以开始书籍解析") + .font(.system(size: 12)) + .foregroundColor(.inkSecondary) + } + + Spacer() + + Button { + parsedDocumentText = nil + parsedFileName = nil + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18)) + .foregroundColor(.inkSecondary) + } + } + .padding(12) + .background(Color.bgWhite) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.brandVital.opacity(0.3), lineWidth: 1) + ) + } + } + + @ViewBuilder + private var submitButton: some View { + Button { + submitBookAction() + } label: { + HStack { + if isLoading { + ProgressView().tint(.white) + .padding(.trailing, 4) + } + Text("书籍解析") + .font(.system(size: 16, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding() + .background(isBookParseDisabled ? Color.inkSecondary.opacity(0.3) : Color.brandVital) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(isBookParseDisabled) + .padding(.bottom, 10) + } + + // MARK: - Logic + + // 书籍解析按钮的禁用逻辑:必须有解析完成的文档或手动输入的文本 + private var isBookParseDisabled: Bool { + if isParsingDocument || isLoading { + return true + } + // 如果有解析完成的文档,可以使用 + if parsedDocumentText != nil { + return false + } + // 否则检查手动输入的文本 + return sourceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + sourceText.count > maxCharCount + } + + /// 处理文档选择 + private func handleDocumentSelection(result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let fileURL = urls.first else { return } + uploadDocument(fileURL: fileURL) + case .failure(let error): + errorMessage = "选择文件失败: \(error.localizedDescription)" + showError = true + } + } + + /// 获取文件的 MIME 类型(使用 UTType,更健壮) + private func getMimeType(for fileURL: URL) -> String? { + let fileExtension = fileURL.pathExtension.lowercased() + + // 使用 UTType 获取 MIME 类型 + if let utType = UTType(filenameExtension: fileExtension) { + return utType.preferredMIMEType + } + + // 兜底:如果 UTType 无法识别,使用硬编码映射 + switch fileExtension { + case "pdf": return "application/pdf" + case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case "doc": return "application/msword" + case "epub": return "application/epub+zip" + default: return nil + } + } + + /// 上传文档并解析文本(独立流程,类似 ChatGPT) + /// ✅ 完全异步,不阻塞UI + private func uploadDocument(fileURL: URL) { + // ✅ 立即更新UI状态,显示加载 + Task { @MainActor in + isParsingDocument = true + } + + // 获取文件信息 + let fileName = fileURL.lastPathComponent + + // 使用优化的 MIME Type 获取方法 + guard let mimeType = getMimeType(for: fileURL) else { + Task { @MainActor in + errorMessage = "不支持的文件格式,仅支持 PDF、Word (.docx) 和 EPUB" + showError = true + isParsingDocument = false + } + return + } + + // ✅ 在后台线程执行上传和解析,不阻塞UI + Task.detached(priority: .userInitiated) { + do { + // 确保文件可访问(从 iCloud 下载) + let _ = fileURL.startAccessingSecurityScopedResource() + defer { fileURL.stopAccessingSecurityScopedResource() } + + // 上传文档并解析文本 + let extractedText = try await AICourseService.shared.uploadDocument( + fileURL: fileURL, + fileName: fileName, + mimeType: mimeType + ) + + // ✅ 存储解析结果,不填入输入框(主线程更新UI) + await MainActor.run { + self.isParsingDocument = false + self.parsedDocumentText = extractedText + self.parsedFileName = fileName + } + } catch { + // ✅ 错误处理也在主线程 + await MainActor.run { + self.isParsingDocument = false + self.errorMessage = "文档解析失败: \(error.localizedDescription)" + self.showError = true + } + } + } + } + + /// 书籍解析:一步生成,直接进入等待页 + /// 优先使用解析完成的文档文本,否则使用手动输入的文本 + private func submitBookAction() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + isLoading = true + + // 优先使用解析完成的文档文本 + let textToUse = parsedDocumentText ?? sourceText + + Task { + do { + let (taskId, courseId) = try await AICourseService.shared.createCourse( + sourceText: textToUse, + sourceType: .document, + persona: .textParseXiaohongshu + ) + await MainActor.run { + self.isLoading = false + // 清除解析状态 + self.parsedDocumentText = nil + self.parsedFileName = nil + path.append(CreateCourseRoute.progress(taskId: taskId, courseId: courseId, thinkingFlowText: thinkingFlowText)) + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.showError = true + self.isLoading = false + } + } + } + } +} + +// ❌ 已删除重复定义的 RoundedCorner 结构体和扩展,使用项目全局定义 diff --git a/ios/WildGrowth/WildGrowth/Views/CreateCourseRoute.swift b/ios/WildGrowth/WildGrowth/Views/CreateCourseRoute.swift new file mode 100644 index 0000000..e69b013 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/CreateCourseRoute.swift @@ -0,0 +1,13 @@ +import SwiftUI + +// MARK: - 创建课程导航路由枚举 +enum CreateCourseRoute: Hashable { + case personaSelection( + sourceText: String, + sourceType: SourceType, + fileName: String? = nil, + thinkingFlowText: String, + parentCourseId: String? = nil + ) + case progress(taskId: String, courseId: String, thinkingFlowText: String) +} diff --git a/ios/WildGrowth/WildGrowth/Views/CreationHubView.swift b/ios/WildGrowth/WildGrowth/Views/CreationHubView.swift new file mode 100644 index 0000000..063b55f --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/CreationHubView.swift @@ -0,0 +1,647 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +// ⚠️ 已移除重复的 enum CreateCourseRoute 定义,直接使用项目中已有的全局定义 + +struct CreationHubView: View { + // MARK: - 逻辑状态 + @State private var magicInput: String = "" + @State private var popularTopics: [String] = [ + "普通人如何做自媒体?", + "怎么提升接话能力?", + "申论人阅卷关注哪些点?", + "给我讲讲《认知觉醒》", + "文科生如何系统学AI?", + "如何提升写作能力?", + "如何学会高等数学?", + ] + /// 思考流文案:前端写死,避免每次进创建页多一次网络请求(约 100–200ms) + @State private var thinkingFlowText: String = AICourseConstants.defaultThinkingFlowText + @State private var userCourses: [Course] = [] + @State private var joinedCourses: [JoinedCourse] = [] // ✅ 新增:用于检查是否有正在创建的课程 + + // 文档上传状态 + @State private var showDocumentPicker = false + @State private var isParsingDocument = false + @State private var parsedDocumentText: String? + @State private var parsedFileName: String? + + // 续旧课状态 + @State private var showContinueCourseSheet = false + @State private var isExtractingCourse = false + @State private var extractedCourseText: String? + @State private var selectedCourse: Course? + + // 贴文字状态 + @State private var showPasteSheet = false + @State private var pastedText: String = "" + + // 导航与反馈 + @State private var path = NavigationPath() + @State private var showError = false + @State private var errorMessage = "" + @State private var showSuccessToast = false + @State private var successToastMessage = "" + + @Environment(\.dismiss) var dismiss + @FocusState private var isInputFocused: Bool + + var onCourseCompleted: ((String) -> Void)? + + // ✅ 检查是否可以创建课程(最多同时3个) + private var canCreateCourse: Bool { + let generatingCount = joinedCourses.filter { $0.isGeneratingStatus }.count + return generatingCount < 3 + } + + var body: some View { + NavigationStack(path: $path) { + ZStack { + // 背景:极淡灰,衬托白色胶囊 + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + + // 点击空白收起键盘 + Color.clear + .contentShape(Rectangle()) + .onTapGesture { isInputFocused = false } + + ScrollView(showsIndicators: false) { + VStack(spacing: 24) { + + // MARK: 1. 顶部悬浮输入区 (Floating Capsule) + VStack(spacing: 16) { + + // A. 主输入胶囊 (自适应高度版) + HStack(alignment: .bottom, spacing: 12) { + // 纯文字输入 + TextField("输入想学习的内容...", text: $magicInput, axis: .vertical) + .font(.system(size: 17)) + .foregroundColor(.primary) + .focused($isInputFocused) + .lineLimit(1...4) + .submitLabel(.go) + .onSubmit { handleIntentSubmit() } + .padding(.leading, 4) + // ✅ 修正1:增加垂直内边距 (4 -> 10) + // 图标高32,文字高约22。为了让单行文字与图标垂直居中,文字需要更厚的"垫子"。 + .padding(.vertical, 10) + + // 发送按钮 + Button { + handleIntentSubmit() + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 32)) + .foregroundColor( + magicInput.isEmpty || !canCreateCourse + ? .gray.opacity(0.2) + : .brandVital + ) + } + .disabled(magicInput.isEmpty || !canCreateCourse) + // ✅ 修正2:调整底部间距 (4 -> 5) + // 计算逻辑:TextField底部有10pt,图标底部给5pt。差值5pt正好填补了图标(32)和文字(22)高度差的一半,从而实现视觉居中。 + .padding(.bottom, 5) + } + .padding(.horizontal, 16) + // 容器本身的 vertical padding 可以稍微减少一点,因为内部撑开了 + .padding(.vertical, 6) + .background(Color.white) + // ✅ UI优化:使用大圆角矩形适配多行输入,避免 Capsule 切角 + .clipShape(RoundedRectangle(cornerRadius: 26)) + .shadow(color: Color.black.opacity(0.08), radius: 12, x: 0, y: 6) + .padding(.horizontal, 20) + + // B. 热门选题 (30字长文适配版) + if !popularTopics.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + // 左侧对齐补白 + Color.clear.frame(width: 20) + + ForEach(popularTopics, id: \.self) { topic in + Button { + withAnimation { + magicInput = topic + isInputFocused = true + } + } label: { + ZStack(alignment: .topLeading) { + // 1. 引号:紧贴卡片左上角,像「开引」的视觉锚点 + Text("\u{201C}") // 中文双引号 " (U+201C) + .font(.system(size: 36, weight: .bold, design: .serif)) + .foregroundColor(.black.opacity(0.07)) + .frame(width: 28, height: 38, alignment: .topLeading) + .offset(x: 6, y: 6) + + // 2. 正文:从引号右下方起排,略收紧间距让引用更一体 + Text(topic) + .font(.system(size: 14, weight: .medium, design: .serif)) + .foregroundColor(.secondary) + .lineLimit(4) + .lineSpacing(3) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, 30) // 再收紧:贴近引号下缘 + .padding(.leading, 26) // 再收紧:贴近引号右缘 + .padding(.trailing, 14) + .padding(.bottom, 12) + } + // ✅ 尺寸升级:160x100,像一张标准的便签纸 + .frame(width: 160, height: 100, alignment: .topLeading) + .background( + Color.white.opacity(0.7) // 磨砂白底 + ) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.6), lineWidth: 0.5) + ) + } + } + + Color.clear.frame(width: 20) + } + } + // 紧贴输入框 + .padding(.top, 4) + } + } + .padding(.top, 40) + + // MARK: 2. 中间分割线 + HStack { + Rectangle().fill(Color.gray.opacity(0.2)).frame(height: 0.5) + Text("或上传资料") + .font(.footnote) + .foregroundColor(.secondary.opacity(0.8)) + .fixedSize() + .padding(.horizontal, 12) + Rectangle().fill(Color.gray.opacity(0.2)).frame(height: 0.5) + } + .padding(.horizontal, 40) + .padding(.vertical, 10) + + // MARK: 3. 底部功能区 (独立胶囊按钮) + VStack(spacing: 16) { + CapsuleActionBtn( + icon: "folder.fill", + title: "导入 PDF / Word / EPUB", + isLoading: isParsingDocument + ) { + // ✅ 清理:移除重复的检查逻辑,因为按钮已经被 .disabled() 了 + showDocumentPicker = true + } + .disabled(!canCreateCourse) + + CapsuleActionBtn( + icon: "doc.text.fill", + title: "粘贴长文本", + isLoading: false + ) { + // ✅ 清理:移除重复的检查逻辑,因为按钮已经被 .disabled() 了 + showPasteSheet = true + } + .disabled(!canCreateCourse) + + CapsuleActionBtn( + icon: "arrow.triangle.2.circlepath", + title: "加更已有内容", + isLoading: isExtractingCourse + ) { + // ✅ 清理:移除重复的检查逻辑,因为按钮已经被 .disabled() 了 + showContinueCourseSheet = true + } + .disabled(!canCreateCourse) + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + } + + // MARK: - Toast (屏幕正中) + if showSuccessToast { + Color.black.opacity(0.01).ignoresSafeArea() + + VStack { + Spacer() + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.white) + .font(.title3) + Text(successToastMessage) + .font(.headline) + .foregroundColor(.white) + } + .padding(.horizontal, 32) + .padding(.vertical, 20) + .background(.ultraThinMaterial) + .background(Color.black.opacity(0.8)) + .cornerRadius(20) + .shadow(radius: 20) + .transition(.scale.combined(with: .opacity)) + + Spacer() + } + .zIndex(100) + } + } + .navigationTitle("创建新内容") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.gray.opacity(0.5)) + } + } + } + .task { + // 并行加载:在 TaskGroup 内直接发起请求,避免捕获 async let + await withTaskGroup(of: Void.self) { group in + // 热门选题已改为前端写死,不再请求接口 + // 思考流已改为前端写死(AICourseConstants.defaultThinkingFlowText),不再请求接口 + group.addTask { + do { + let courses = try await AICourseService.shared.getUserCreatedCourses() + await MainActor.run { userCourses = courses } + } catch { + // 用户课程加载失败不影响功能 + } + } + group.addTask { + do { + let joined = try await CourseService.shared.fetchJoinedCourses() + await MainActor.run { joinedCourses = joined } + } catch { + // 已加入课程加载失败不影响功能 + } + } + } + } + .fileImporter( + isPresented: $showDocumentPicker, + allowedContentTypes: [ + .pdf, + UTType(filenameExtension: "docx") ?? .data, + UTType(filenameExtension: "doc") ?? .data, + UTType(filenameExtension: "epub") ?? .data, + .plainText + ], + allowsMultipleSelection: false + ) { result in handleDocumentSelection(result: result) } + + .sheet(isPresented: $showPasteSheet) { + PasteTextView(text: $pastedText, onConfirm: { text in + handlePasteSubmit(text) + }) + } + + .sheet(isPresented: $showContinueCourseSheet) { + ContinueCourseSheet( + courses: userCourses, + isLoading: isExtractingCourse, + onSelect: { course in extractCourseContent(course: course) } + ) + } + + .navigationDestination(for: CreateCourseRoute.self) { route in + switch route { + case .personaSelection(let sourceText, let sourceType, let fileName, let flowText, let parentCourseId): + PersonaSelectionView( + sourceText: sourceText, + sourceType: sourceType, + fileName: fileName, + thinkingFlowText: flowText, + parentCourseId: parentCourseId, + navigationPath: $path + ) + case .progress(let taskId, let courseId, let flowText): + // ✅ 修复:尝试从 joinedCourses 中获取 joinedAt + let course = joinedCourses.first(where: { $0.id == courseId }) + GenerationProgressView( + taskId: taskId, + courseId: courseId, + thinkingFlowText: flowText, + joinedAt: course?.joinedAt, // ✅ 传递课程创建时间 + onCompletion: { cid in + dismiss() + onCourseCompleted?(cid) + }, + onCloseTapped: { dismiss() } + ) + } + } + .alert("操作失败", isPresented: $showError) { + Button("确定", role: .cancel) { } + } message: { Text(errorMessage) } + } + } + + // MARK: - Logic Helpers + + private func handleIntentSubmit() { + guard !magicInput.isEmpty else { return } + AnalyticsManager.shared.track("ai_creation_start", properties: ["source_type": "direct"]) + navigateToPersonaSelection(sourceText: magicInput, sourceType: .direct) + } + + private func handlePasteSubmit(_ text: String) { + AnalyticsManager.shared.track("ai_creation_start", properties: ["source_type": "paste"]) + showSuccessToast(message: "内容已选择") { + self.navigateToPersonaSelection(sourceText: text, sourceType: .document) + } + } + + private func handleDocumentSelection(result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let fileURL = urls.first else { return } + uploadDocument(fileURL: fileURL) + case .failure(let error): + errorMessage = "选择文件失败: \(error.localizedDescription)" + showError = true + } + } + + private func uploadDocument(fileURL: URL) { + AnalyticsManager.shared.track("ai_creation_start", properties: ["source_type": "document"]) + isParsingDocument = true + let fileName = fileURL.lastPathComponent + guard let mimeType = getMimeType(for: fileURL) else { + Task { @MainActor in + isParsingDocument = false + errorMessage = "不支持的文件格式" + showError = true + } + return + } + + Task { + do { + let _ = fileURL.startAccessingSecurityScopedResource() + defer { fileURL.stopAccessingSecurityScopedResource() } + + let extractedText = try await AICourseService.shared.uploadDocument( + fileURL: fileURL, + fileName: fileName, + mimeType: mimeType + ) + + await MainActor.run { + self.isParsingDocument = false + self.parsedDocumentText = extractedText + self.parsedFileName = fileName + self.showSuccessToast(message: "内容已选择") { + self.navigateToPersonaSelection(sourceText: extractedText, sourceType: .document, fileName: fileName) + } + } + } catch { + await MainActor.run { + isParsingDocument = false + errorMessage = "文档解析失败: \(error.localizedDescription)" + showError = true + } + } + } + } + + private func extractCourseContent(course: Course) { + Task { @MainActor in + isExtractingCourse = true + selectedCourse = course + } + + Task.detached(priority: .userInitiated) { + do { + let extractedText = try await AICourseService.shared.extractCourseContent(courseId: course.id) + await MainActor.run { + self.isExtractingCourse = false + self.extractedCourseText = extractedText + self.showContinueCourseSheet = false + self.showSuccessToast(message: "内容已选择") { + self.navigateToPersonaSelection(sourceText: extractedText, sourceType: .continue, parentCourseId: course.id) + } + } + } catch { + await MainActor.run { + self.isExtractingCourse = false + self.showContinueCourseSheet = false + self.errorMessage = "提取失败: \(error.localizedDescription)" + self.showError = true + } + } + } + } + + private func navigateToPersonaSelection(sourceText: String, sourceType: SourceType, fileName: String? = nil, parentCourseId: String? = nil) { + path.append(CreateCourseRoute.personaSelection( + sourceText: sourceText, + sourceType: sourceType, + fileName: fileName, + thinkingFlowText: thinkingFlowText, + parentCourseId: parentCourseId + )) + } + + private func showSuccessToast(message: String, completion: @escaping () -> Void) { + successToastMessage = message + withAnimation { showSuccessToast = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { showSuccessToast = false } + completion() + } + } + + private func getMimeType(for fileURL: URL) -> String? { + let fileExtension = fileURL.pathExtension.lowercased() + if let utType = UTType(filenameExtension: fileExtension) { return utType.preferredMIMEType } + switch fileExtension { + case "pdf": return "application/pdf" + case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case "doc": return "application/msword" + case "epub": return "application/epub+zip" + case "txt": return "text/plain" + default: return nil + } + } +} + +// MARK: - 独立胶囊按钮组件 (纯黑图标,无副标题) +struct CapsuleActionBtn: View { + let icon: String + let title: String + let isLoading: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 16) { + ZStack { + if isLoading { + ProgressView() + .tint(.black) + .scaleEffect(0.8) + } else { + Image(systemName: icon) + .font(.system(size: 18, weight: .regular)) + .foregroundColor(.black) + } + } + .frame(width: 24, height: 24) + + Text(title) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.black) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray.opacity(0.4)) + } + .padding(.vertical, 18) + .padding(.horizontal, 20) + .background(Color.white) + .clipShape(Capsule()) + .shadow(color: Color.black.opacity(0.03), radius: 8, x: 0, y: 3) + } + .buttonStyle(ScaleButtonStyle()) + .disabled(isLoading) + } +} + +// MARK: - Paste Text Sheet (含 Placeholder) +struct PasteTextView: View { + @Binding var text: String + let onConfirm: (String) -> Void + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + ZStack(alignment: .topLeading) { + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 16) { + ZStack(alignment: .topLeading) { + TextEditor(text: $text) + .font(.body) + .padding(12) + .background(Color.bgWhite) + .cornerRadius(12) + .frame(maxHeight: .infinity) + + if text.isEmpty { + Text("粘贴长文本和笔记...") + .font(.body) + .foregroundColor(.gray.opacity(0.5)) + .padding(.top, 20) + .padding(.leading, 16) + .allowsHitTesting(false) + } + } + + Button { + onConfirm(text) + dismiss() + } label: { + Text("确认") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(text.isEmpty ? Color.inkSecondary.opacity(0.3) : Color.brandVital) + .cornerRadius(12) + } + .disabled(text.isEmpty) + } + .padding(20) + } + .navigationTitle("粘贴内容") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("取消") { dismiss() } + } + } + } + } +} + +// MARK: - Continue Course Sheet +struct ContinueCourseSheet: View { + let courses: [Course] + let isLoading: Bool + let onSelect: (Course) -> Void + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + Group { + if courses.isEmpty { + VStack(spacing: 16) { + Image(systemName: "book.closed") + .font(.system(size: 40)) + .foregroundColor(.secondary.opacity(0.5)) + Text("暂无可加更的内容") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground)) + } else { + ZStack { + List { + Section(header: Text("已加入的内容")) { + ForEach(courses) { course in + Button { + onSelect(course) + } label: { + HStack { + Text(course.title) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + .padding(.vertical, 4) + + Spacer() + + if isLoading { + ProgressView().tint(.brandVital) + } + } + } + .disabled(isLoading) + } + } + } + + if isLoading { + Color.black.opacity(0.1) + .ignoresSafeArea() + .overlay( + VStack(spacing: 12) { + ProgressView().scaleEffect(1.2) + Text("正在准备加更内容...") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + .padding(20) + .background(Color.bgWhite) + .cornerRadius(12) + ) + } + } + } + } + .navigationTitle("选择内容") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("取消") { dismiss() } + } + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/DiscoveryComponents.swift b/ios/WildGrowth/WildGrowth/Views/DiscoveryComponents.swift new file mode 100644 index 0000000..d5121b4 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/DiscoveryComponents.swift @@ -0,0 +1,184 @@ +import SwiftUI +import Kingfisher + +// MARK: - 🎨 公共逻辑:动态渐变生成器 +private extension View { + func cardGradient(for hex: String?, id: String) -> LinearGradient { + if let hex = hex, !hex.isEmpty { + let base = Color(hex: hex) + return LinearGradient( + colors: [base, base.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + // 兜底:使用 ID 生成的确定性渐变 + return LinearGradient.courseCardGradients[abs(id.hashValue) % LinearGradient.courseCardGradients.count] + } + } +} + +// MARK: - 🎨 组件:运营位单张卡片 (Banner Card) +// 逻辑对齐:完全由后端数据驱动 (封面、主题色、水印图标) +struct OperationalBannerCard: View { + let item: BannerCourseItem + + // URL 处理 + private var coverURL: URL? { + if item.coverImage.isEmpty { return nil } + if item.coverImage.hasPrefix("http") { return URL(string: item.coverImage) } + let path = item.coverImage.hasPrefix("/") ? item.coverImage : "/" + item.coverImage + return URL(string: APIClient.shared.baseURL + path) + } + + // 安全标题 + private var safeTitle: String { + item.title.isEmpty ? "课程创建中" : item.title + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + // === Layer 1: 背景层 === + if let url = coverURL { + KFImage(url) + .resizable() + .placeholder { + cardGradient(for: item.themeColor, id: item.id) + } + .fade(duration: 0.25) + .aspectRatio(contentMode: .fill) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } else { + cardGradient(for: item.themeColor, id: item.id) + } + + // === Layer 2: 水印叠层 (后端驱动) === + if let iconName = item.watermarkIcon, !iconName.isEmpty { + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: iconName) + .font(.system(size: geo.size.width * 1.0)) + .foregroundColor(.white.opacity(0.15)) + .rotationEffect(.degrees(-15)) + .offset(x: geo.size.width * 0.3, y: geo.size.height * 0.2) + } + } + .clipped() + } + + // === Layer 3: 标题区 (左上角) === + if !item.title.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text(safeTitle) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .lineSpacing(2) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + + Spacer() + } + .padding(12) + // ✅ 核心修复:强制约束容器宽度,确保 Padding 向内挤压而非向外撑大 + .frame(width: geo.size.width, alignment: .topLeading) + } + } + } + .frame(width: 110, height: 150) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + .shadow(color: Color.brandVital.opacity(0.3), radius: 6, x: 0, y: 3) + } +} + +// MARK: - 🎨 组件:信息流卡片 (Feed Card) +// 逻辑对齐:完全由后端数据驱动,布局复刻 ProfileCourseCard +struct DiscoveryFeedCard: View { + let course: Course + let isSquare: Bool + + private var coverURL: URL? { + guard !course.coverImage.isEmpty else { return nil } + if course.coverImage.hasPrefix("http") { return URL(string: course.coverImage) } + let path = course.coverImage.hasPrefix("/") ? course.coverImage : "/" + course.coverImage + return URL(string: APIClient.shared.baseURL + path) + } + + private var safeTitle: String { + course.title.isEmpty ? "课程创建中" : course.title + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + // === Layer 1: 背景层 === + if let url = coverURL { + KFImage(url) + .resizable() + .placeholder { + Rectangle().fill(cardGradient(for: course.themeColor, id: course.id)) + } + .aspectRatio(contentMode: .fill) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } else { + // 无图:显示主题色渐变 + Rectangle().fill(cardGradient(for: course.themeColor, id: course.id)) + } + + // === Layer 2: 水印叠层 (后端驱动) === + // 严控数据:有图标才显示 + if let iconName = course.watermarkIcon, !iconName.isEmpty { + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: iconName) + // 几何算法:自适应 1:1 或 3:4 + .font(.system(size: geo.size.width * 1.0)) + .foregroundColor(.white.opacity(0.15)) + .rotationEffect(.degrees(-15)) + .offset( + x: geo.size.width * 0.3, + y: geo.size.height * 0.2 + ) + } + } + .clipped() + } + + // === Layer 3: 标题区 (左上角) === + VStack(alignment: .leading, spacing: 6) { + Text(safeTitle) + .font(.system(size: 20, weight: .bold)) // 霸气大字号 + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .lineSpacing(4) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + + Spacer() + } + .padding(16) + } + } + .aspectRatio(isSquare ? 1.0 : 0.75, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + // 质感:玻璃描边 + 品牌色投影 + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + .shadow(color: Color.brandVital.opacity(0.3), radius: 6, x: 0, y: 3) + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/DiscoveryView.swift b/ios/WildGrowth/WildGrowth/Views/DiscoveryView.swift new file mode 100644 index 0000000..c57ae9e --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/DiscoveryView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +struct DiscoveryView: View { + @EnvironmentObject var navStore: NavigationStore + + // 数据状态 + @State private var banners: [OperationalBanner] = [] + @State private var courses: [Course] = [] + @State private var isLoading = true + + // 瀑布流数据源:将课程分为左右两列 + @State private var leftColumnCourses: [Course] = [] + @State private var rightColumnCourses: [Course] = [] + + // 布局间距 + private let spacing: CGFloat = 10 // 更紧凑的间距 + + var body: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + if isLoading && courses.isEmpty { + ProgressView() + .tint(.brandVital) + } else { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + // MARK: - Section 1: 运营位(顶到顶部,无「推荐」标题) + if !banners.isEmpty { + VStack(spacing: 20) { + ForEach(banners) { banner in + VStack(alignment: .leading, spacing: 12) { + Text(banner.title) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.inkPrimary) + .padding(.horizontal, 20) + + // 横滑卡片 (保持不变) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 12) { + ForEach(banner.courses) { item in + Button { + navigateToCourse(id: item.id) + } label: { + OperationalBannerCard(item: item) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 20) + } + .frame(height: 150 + 10) // 160:150 卡片 + 10 冗余 + } + } + } + .padding(.top, 10) + } + + // 信息流顶部分割线 + HStack(spacing: 12) { + Rectangle() + .fill(Color.black.opacity(0.05)) + .frame(height: 1) + + Text("每日更新") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.inkSecondary.opacity(0.7)) + + Rectangle() + .fill(Color.black.opacity(0.05)) + .frame(height: 1) + } + .padding(.horizontal, 60) // 控制线条长度,不撑满屏幕 + .padding(.vertical, 24) // 上下舒适的留白 + + // MARK: - Section 2: 错落瀑布流 (Masonry) + // 使用 HStack + 两个 VStack 实现真实的错落感 + HStack(alignment: .top, spacing: spacing) { + // 左列 + LazyVStack(spacing: spacing) { + ForEach(leftColumnCourses) { course in + Button { + navigateToCourse(id: course.id) + } label: { + // 逻辑:ID 哈希偶数为 1:1,奇数为 3:4 (确定性随机) + DiscoveryFeedCard(course: course, isSquare: isSquare(course)) + } + .buttonStyle(PlainButtonStyle()) + } + } + + // 右列 + LazyVStack(spacing: spacing) { + ForEach(rightColumnCourses) { course in + Button { + navigateToCourse(id: course.id) + } label: { + DiscoveryFeedCard(course: course, isSquare: isSquare(course)) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 100) // 底部留白 + } + } + .refreshable { + await loadData() + } + } + } + // 核心:导航处理 + .navigationDestination(for: CourseNavigation.self) { destination in + switch destination { + case .map(let courseId): + MapView(courseId: courseId, navigationPath: $navStore.homePath) + case .player(let courseId, let nodeId, let isLastNode, let courseTitle): + VerticalScreenPlayerView( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: nil, + navigationPath: $navStore.homePath, + isLastNode: isLastNode, + courseTitle: courseTitle + ) + } + } + .toolbar(.hidden, for: .navigationBar) + .onAppear { + AnalyticsManager.shared.track("discovery_view") + if courses.isEmpty { + Task { await loadData() } + } + } + } + + // MARK: - 辅助逻辑 + + // 确定性随机逻辑:判断是否显示为正方形 + // ✅ 修改:将模数从 2 改为 3 + // 效果:约 33% 是正方形,67% 是长方形。 + // 这样视觉上长方形为主,正方形为"破局点",错落感更自然,不生硬。 + private func isSquare(_ course: Course) -> Bool { + return abs(course.id.hashValue) % 3 == 0 + } + + func loadData() async { + do { + async let fetchedBanners = CourseService.shared.getOperationalBanners() + async let fetchedCourses = CourseService.shared.getDiscoveryFeed() + + let (newBanners, newCourses) = try await (fetchedBanners, fetchedCourses) + + await MainActor.run { + self.banners = newBanners + self.courses = newCourses + self.distributeCourses(newCourses) // 分配左右列 + self.isLoading = false + } + } catch { + print("❌ Discovery load failed: \(error)") + await MainActor.run { + self.isLoading = false + } + } + } + + // 将课程分配到左右两列 + private func distributeCourses(_ allCourses: [Course]) { + var left: [Course] = [] + var right: [Course] = [] + + // 简单交替分配 (0, 2, 4 -> 左; 1, 3, 5 -> 右) + for (index, course) in allCourses.enumerated() { + if index % 2 == 0 { + left.append(course) + } else { + right.append(course) + } + } + + self.leftColumnCourses = left + self.rightColumnCourses = right + } + + func navigateToCourse(id: String) { + AnalyticsManager.shared.track("discovery_course_click", properties: ["course_id": id]) + navStore.homePath.append(CourseNavigation.map(courseId: id)) + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/EditCourseNameSheet.swift b/ios/WildGrowth/WildGrowth/Views/EditCourseNameSheet.swift new file mode 100644 index 0000000..e2a6dcb --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/EditCourseNameSheet.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct EditCourseNameSheet: View { + let course: JoinedCourse + let onSave: (String) -> Void + + @Environment(\.presentationMode) var presentationMode + @State private var courseTitle: String + @State private var isLoading = false + + // ✅ 修复:在 init 中初始化 courseTitle,避免第一次打开时空白 + init(course: JoinedCourse, onSave: @escaping (String) -> Void) { + self.course = course + self.onSave = onSave + _courseTitle = State(initialValue: course.title) + } + + var body: some View { + NavigationView { + ZStack { + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 24) { + // 标题 + Text("修改课程名称") + .font(.heading()) + .foregroundColor(.inkPrimary) + .padding(.top, 32) + + // 输入框 + VStack(alignment: .leading, spacing: 8) { + Text("课程名称") + .font(.labelMedium()) + .foregroundColor(.inkSecondary) + + TextField("请输入课程名称", text: $courseTitle) + .font(.bodyText()) + .foregroundColor(.inkPrimary) + .padding(16) + .background(Color.bgWhite) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + courseTitle.count > 30 ? Color.red.opacity(0.5) : Color.inkSecondary.opacity(0.1), + lineWidth: 1 + ) + ) + .onChange(of: courseTitle) { _, newValue in + // 限制最多30个字 + if newValue.count > 30 { + courseTitle = String(newValue.prefix(30)) + } + } + + // 字数提示 + HStack { + Spacer() + Text("\(courseTitle.count)/30") + .font(.caption) + .foregroundColor(courseTitle.count > 30 ? .red : .inkSecondary) + } + } + .padding(.horizontal, 20) + + Spacer() + + // 保存按钮 + Button(action: { + guard !courseTitle.trimmingCharacters(in: .whitespaces).isEmpty else { return } + let trimmedTitle = courseTitle.trimmingCharacters(in: .whitespaces) + guard trimmedTitle != course.title else { + // 如果没有修改,直接关闭 + presentationMode.wrappedValue.dismiss() + return + } + guard trimmedTitle.count <= 30 else { return } + isLoading = true + onSave(trimmedTitle) + }) { + if isLoading { + ProgressView() + .tint(.white) + } else { + Text("保存") + .font(.system(size: 18, weight: .bold)) + } + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background( + courseTitle.trimmingCharacters(in: .whitespaces).isEmpty || courseTitle.count > 30 + ? Color.gray.opacity(0.3) + : Color.brandVital + ) + .cornerRadius(28) + .disabled(courseTitle.trimmingCharacters(in: .whitespaces).isEmpty || courseTitle.count > 30 || isLoading) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("取消") { + presentationMode.wrappedValue.dismiss() + } + .foregroundColor(.inkSecondary) + } + } + } + .navigationViewStyle(.stack) + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/GenerationProgressView.swift b/ios/WildGrowth/WildGrowth/Views/GenerationProgressView.swift new file mode 100644 index 0000000..7d85d01 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/GenerationProgressView.swift @@ -0,0 +1,329 @@ +import SwiftUI + +struct GenerationProgressView: View { + // 基础属性 + let taskId: String + let courseId: String + let thinkingFlowText: String + let joinedAt: String? // ✅ 新增:课程创建时间(ISO字符串) + var onCompletion: ((String) -> Void)? + /// 点击左上角 X 时调用,用于关闭整个浮窗(由父级传入 dismiss) + var onCloseTapped: (() -> Void)? + + @StateObject private var poller: TaskStatusPoller + @Environment(\.dismiss) var dismiss + + // UI 状态 + @State private var showTimeoutAlert = false + @State private var displayedThinkingText = "" + @State private var thinkingTextIndex = 0 + @State private var showCursor = true + + // ✅ 平滑进度相关状态 + @State private var displayedProgress: Int = 0 + @State private var taskStartTime: Date? + + // Timer 管理 (防止内存泄漏) + @State private var cursorTimer: Timer? + @State private var thinkingTimer: Timer? + @State private var progressTimer: Timer? + + // 兜底文案库 + private let defaultThinkingFlowTexts = [ + "正在分析上下文...", "构建知识图谱中...", "提取关键概念...", + "组织知识结构...", "生成学习路径...", "优化内容呈现..." + ] + + private var displayThinkingFlowText: String { + if thinkingFlowText.isEmpty { + return defaultThinkingFlowTexts.randomElement() ?? "正在处理中..." + } + return thinkingFlowText + } + + init(taskId: String, courseId: String, thinkingFlowText: String, joinedAt: String? = nil, onCompletion: ((String) -> Void)? = nil, onCloseTapped: (() -> Void)? = nil) { + self.taskId = taskId + self.courseId = courseId + self.thinkingFlowText = thinkingFlowText + self.joinedAt = joinedAt + self.onCompletion = onCompletion + self.onCloseTapped = onCloseTapped + _poller = StateObject(wrappedValue: TaskStatusPoller(taskId: taskId)) + } + + var body: some View { + ZStack { + Color.bgPaper.ignoresSafeArea() + + // MARK: - 状态一:生成中 / 失败 + if poller.status != .completed { + VStack(spacing: 40) { + Spacer() + + // 1. 进度圆环与核心状态 + VStack(spacing: 30) { + // 圆环 + ZStack { + // 底圈 + Circle() + .stroke(Color.gray.opacity(0.1), lineWidth: 8) + .frame(width: 140, height: 140) + + // 进度圈 (品牌色,使用平滑进度) + if poller.status != .failed { + Circle() + .trim(from: 0, to: CGFloat(displayedProgress) / 100.0) + .stroke( + Color.brandVital, // 回归纯净品牌色 + style: StrokeStyle(lineWidth: 8, lineCap: .round) + ) + .frame(width: 140, height: 140) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.3), value: displayedProgress) + } + + // 中心数字 (等宽字体防抖 + 滚动动效,使用平滑进度) + if poller.status == .failed { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 40)) + .foregroundColor(.red) + } else { + Text("\(displayedProgress)%") + .font(.system(size: 36, weight: .bold, design: .monospaced)) // Monospaced 防左右抖动 + .foregroundColor(.inkPrimary) + .contentTransition(.numericText()) // ✅ 修复:移除错误的 value 参数 + } + } + + // 文案分层 + VStack(spacing: 12) { + if poller.status == .failed { + Text("创建中断") + .font(.title3.bold()) + .foregroundColor(.primary) + Text(poller.errorMessage ?? "请检查网络或稍后重试") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } else { + Text("专属内容创建中") + .font(.title3.bold()) + .foregroundColor(.primary) + + Text("为确保内容足够吸引你,生成约需3分钟\n期间可以关闭窗口,不影响进度") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + } + .padding(.horizontal, 24) + } + + // 2. 底部思考流 (打字机 + 光标,样式与之前一致:固定高度 100,内容可滚动打完所有字符) + if poller.status != .failed { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + Text(displayedThinkingText + (showCursor ? " |" : " ")) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.secondary) + .lineSpacing(4) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .animation(.none, value: showCursor) + .id("thinkingBottom") + } + .padding(20) + .background(Color.bgWhite) + .cornerRadius(12) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.gray.opacity(0.1), lineWidth: 1)) + .padding(.horizontal, 30) + .frame(height: 100) + .onChange(of: displayedThinkingText) { _, _ in + withAnimation(.easeOut(duration: 0.12)) { + proxy.scrollTo("thinkingBottom", anchor: .bottom) + } + } + } + } + + Spacer() + + // 失败状态下的按钮 + if poller.status == .failed { + Button { + dismiss() + } label: { + Text("返回修改") + .fontWeight(.semibold) + .frame(width: 200, height: 50) + .background(Color.bgWhite) + .foregroundColor(.inkPrimary) + .cornerRadius(25) + .overlay(RoundedRectangle(cornerRadius: 25).stroke(Color.gray.opacity(0.3))) + } + .padding(.bottom, 40) + } + } + } + + // MARK: - 状态二:完成 (中心聚合布局) + if poller.status == .completed { + VStack(spacing: 30) { + // 大图标 + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 88)) + .foregroundColor(.brandVital) + .symbolEffect(.bounce, value: true) // iOS 17 入场动效 + + // 极简文字 + Text("创建完成") + .font(.title2.bold()) + .foregroundColor(.primary) + + // 胶囊按钮 (收窄宽度) + Button { + onCompletion?(courseId) + } label: { + Text("开始成长") + .font(.headline) + .frame(width: 220, height: 54) // 固定宽度,显得精致 + .background(Color.brandVital) + .foregroundColor(.white) + .clipShape(Capsule()) + .shadow(color: Color.brandVital.opacity(0.3), radius: 10, y: 5) + } + } + .transition(.opacity.combined(with: .scale(scale: 0.9))) // 优雅的淡入 + } + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + // 生成中允许退出 (后台继续) + if poller.status != .completed { + Button { + poller.stop() // 停止轮询即可,不发取消请求 + if let close = onCloseTapped { + close() // 关闭整个浮窗 + } else { + dismiss() + } + } label: { + Image(systemName: "xmark") // 用 xmark 表示关闭窗口 + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.secondary) + .padding(8) + .background(Color.bgWhite) + .clipShape(Circle()) + } + } + } + } + .task { + setupPoller() + startThinkingFlow() + startSmoothProgress() + + // ✅ 修复:保存 Timer 引用 + cursorTimer = Timer.scheduledTimer(withTimeInterval: 0.6, repeats: true) { _ in + showCursor.toggle() + } + } + .onDisappear { + poller.stop() + stopSmoothProgress() + // ✅ 修复:销毁所有 Timer + cursorTimer?.invalidate() + thinkingTimer?.invalidate() + } + .alert("生成超时", isPresented: $showTimeoutAlert) { + Button("返回", role: .cancel) { dismiss() } + } message: { Text("请检查网络或稍后重试") } + } + + // MARK: - Logic Methods + + private func setupPoller() { + print("🔵 [Progress] 开始轮询: \(taskId)") + poller.onCompleted = { courseId in + print("✅ [Progress] 生成完成: \(courseId)") + AnalyticsManager.shared.track("ai_creation_success", properties: ["course_id": courseId]) + poller.stop() + } + poller.onFailed = { error in + print("❌ [Progress] 生成失败: \(error)") + AnalyticsManager.shared.track("ai_creation_fail", properties: ["error": error]) + } + poller.onTimeout = { + print("⏰ [Progress] 轮询超时") + showTimeoutAlert = true + } + poller.start() + } + + private func startThinkingFlow() { + let textToDisplay = displayThinkingFlowText + guard !textToDisplay.isEmpty else { return } + + displayedThinkingText = "" + thinkingTextIndex = 0 + + // 打字机速度:约 33 字/秒(0.03s/字),快一倍 + thinkingTimer = Timer.scheduledTimer(withTimeInterval: 0.03, repeats: true) { timer in + if thinkingTextIndex < textToDisplay.count { + let index = textToDisplay.index(textToDisplay.startIndex, offsetBy: thinkingTextIndex) + displayedThinkingText.append(textToDisplay[index]) + thinkingTextIndex += 1 + } else { + timer.invalidate() + } + } + } + + // MARK: - 平滑进度管理 + + private func startSmoothProgress() { + // 停止旧的定时器 + stopSmoothProgress() + + // ✅ 优化:优先使用任务创建时间(从 API 获取),其次使用 joinedAt,最后使用当前时间 + if taskStartTime == nil { + // 优先使用任务创建时间(最准确) + if let createdAt = poller.taskCreatedAt { + taskStartTime = SmoothProgressCalculator.parseDate(from: createdAt) + } + // 其次使用课程的 joinedAt + if taskStartTime == nil, let joinedAtString = joinedAt { + taskStartTime = SmoothProgressCalculator.parseDate(from: joinedAtString) + } + // 最后使用当前时间(兜底) + if taskStartTime == nil { + taskStartTime = Date() + } + } + + // 立即计算一次 + let realProgress = poller.progress + displayedProgress = SmoothProgressCalculator.calculate( + startTime: taskStartTime, + realProgress: realProgress + ) + + // 每0.1秒更新一次进度(在 MainActor 上读取 progress,避免 Sendable 闭包引用 MainActor 属性) + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + Task { @MainActor in + let realProgress = poller.progress + displayedProgress = SmoothProgressCalculator.calculate( + startTime: taskStartTime, + realProgress: realProgress + ) + } + } + } + + private func stopSmoothProgress() { + progressTimer?.invalidate() + progressTimer = nil + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Growth/GrowthTopBar.swift b/ios/WildGrowth/WildGrowth/Views/Growth/GrowthTopBar.swift new file mode 100644 index 0000000..cefad6e --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Growth/GrowthTopBar.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct GrowthTopBar: View { + @Binding var selectedTab: Int + var onCreateCourse: () -> Void + var isDisabled: Bool = false // ✅ 新增:禁用状态 + + var body: some View { + HStack(alignment: .lastTextBaseline, spacing: 0) { + + // 左侧:Tab 标题区(只保留"内容") + HStack(alignment: .bottom, spacing: 24) { + TabButton(title: "内容", index: 0, selectedTab: $selectedTab) + // ✅ 移除"笔记" Tab(笔记已移到 ProfileView) + } + + Spacer() + + // 右侧:创建内容按钮(无填充胶囊 + plus,与左侧标题同色) + Button(action: onCreateCourse) { + HStack(spacing: 4) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + Text("创建内容") + .font(.system(size: 14, weight: .semibold)) + } + .foregroundColor(isDisabled ? .inkSecondary.opacity(0.4) : .inkPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .overlay( + Capsule() + .strokeBorder(Color.inkSecondary.opacity(isDisabled ? 0.15 : 0.35), lineWidth: 1) + ) + } + .disabled(isDisabled) // ✅ 禁用按钮 + // ✅ 修正 4: 增加按压缩放反馈,找回交互亲和力 + .buttonStyle(ScaleButtonStyle()) + } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 10) + .background(Color.bgPaper) // ✅ 修正:使用 bgPaper 保持一致性 + } +} + +// 内部子组件:Tab 按钮 +struct TabButton: View { + let title: String + let index: Int + @Binding var selectedTab: Int + + var isSelected: Bool { selectedTab == index } + + var body: some View { + Button { + // ✅ 修正:Tab 标签切换使用快速淡入淡出动画(标准 iOS Tab 切换体验) + // 内容区域应该瞬间切换,不使用动画(通过 transaction 禁用) + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = index + } + } label: { + VStack(spacing: 4) { + Text(title) + // ✅ 修正:22pt Bold(选中),18pt Medium(未选中)- 更平衡 + .font(.system(size: isSelected ? 22 : 18, weight: isSelected ? .bold : .medium)) + .foregroundColor(isSelected ? .inkPrimary : .inkSecondary) + // ✅ 移除 scaleEffect,避免视觉混乱(只保留颜色和字号变化) + } + } + .buttonStyle(.plain) + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/GrowthView.swift b/ios/WildGrowth/WildGrowth/Views/GrowthView.swift new file mode 100644 index 0000000..fa6b4bc --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/GrowthView.swift @@ -0,0 +1,660 @@ +import SwiftUI +import Kingfisher + +struct GrowthView: View { + @EnvironmentObject var navStore: NavigationStore + @StateObject private var userManager = UserManager.shared + + // UI 状态 + @State private var selectedTab = 0 + @State private var joinedCourses: [JoinedCourse] = [] + @State private var courses: [Course] = [] // ✅ 新增:所有课程列表(用于判断 isPortrait) + @State private var isLoadingCourses = false + + // ✅ 删除相关状态(统一使用 JoinedCourse 对象) + @State private var courseToDelete: JoinedCourse? + @State private var showDeleteAlert = false + @State private var showErrorToast = false + @State private var errorMessage = "" + + // ✅ 修改课程名称状态(使用 item-based sheet,避免白屏问题) + @State private var courseToEdit: JoinedCourse? + + // ✅ AI 创建课程状态 + @State private var showCreateCourseSheet = false + @State private var pendingJumpCourseId: String? // ✅ 新增:待跳转的课程ID + + // ✅ Toast 提示状态 + @State private var showSuccessToast = false + @State private var toastMessage = "" + + // ✅ 进度轮询定时器(用于更新列表中创建中课程的进度) + @State private var progressUpdateTimer: Timer? + + // ✅ 技能页游客模式:登录弹窗 + @State private var showLoginSheet = false + + // ✅ 检查是否可以创建课程(最多同时3个) + private var canCreateCourse: Bool { + let generatingCount = joinedCourses.filter { $0.isGeneratingStatus }.count + return generatingCount < 3 + } + + let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + + var body: some View { + VStack(spacing: 0) { + // ✅ 1. 使用新的沉浸式顶部导航栏 + GrowthTopBar( + selectedTab: $selectedTab, + onCreateCourse: { + if userManager.isLoggedIn { + showCreateCourseSheet = true + } else { + showLoginSheet = true // 游客点击「创建内容」时唤起登录浮窗 + } + }, + isDisabled: userManager.isLoggedIn && !canCreateCourse // 游客不禁用;已登录时若已有3个正在创建则禁用 + ) + + // 2. 列表内容区域(游客显示登录引导,登录后显示空状态或课程列表) + if !userManager.isLoggedIn { + growthGuestPromptCard + } else { + ScrollView(showsIndicators: false) { + if joinedCourses.isEmpty && !isLoadingCourses { + // 空状态(已登录、无课程) + VStack(spacing: 12) { + Image(systemName: "book.closed") + .font(.system(size: 40)) + .foregroundColor(.inkSecondary.opacity(0.5)) + Text("还没有学习内容") + .foregroundColor(.inkSecondary) + } + .padding(.top, 60) + } else { + courseGridView + } + } + } + } + .background(Color.bgPaper.ignoresSafeArea()) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .navigationBar) // ✅ 修正:使用新 API 隐藏导航栏 + .navigationDestination(for: CourseNavigation.self) { destination in + switch destination { + case .map(let courseId): + MapView(courseId: courseId, navigationPath: $navStore.growthPath) + case .player(let courseId, let nodeId, let isLastNode, let courseTitle): + VerticalScreenPlayerView( + courseId: courseId, + nodeId: nodeId, + initialScrollIndex: nil, + navigationPath: $navStore.growthPath, + isLastNode: isLastNode, + courseTitle: courseTitle + ) + } + } + // ✅ AI 创建课程 Sheet(使用新的 CreationHubView) + .sheet(isPresented: $showCreateCourseSheet) { + CreationHubView( + onCourseCompleted: { courseId in + // ✅ 接收回调,记录待跳转的课程ID + pendingJumpCourseId = courseId + } + ) + .environmentObject(navStore) + .onDisappear { + // ✅ Sheet 关闭后刷新课程列表(确保新创建的课程显示出来) + loadCourses() + + // ✅ Sheet 关闭后执行跳转(如果课程已完成) + if let courseId = pendingJumpCourseId { + navStore.growthPath.append(CourseNavigation.map(courseId: courseId)) + pendingJumpCourseId = nil + } + } + } + .sheet(isPresented: $showLoginSheet) { + LoginView( + isLoggedIn: Binding( + get: { UserManager.shared.isLoggedIn }, + set: { UserManager.shared.isLoggedIn = $0 } + ), + onClose: { showLoginSheet = false } + ) + } + // ✅ 二次确认弹窗 + .alert("移除课程", isPresented: $showDeleteAlert, presenting: courseToDelete) { course in + Button("取消", role: .cancel) { + self.courseToDelete = nil + } + Button("确认移除", role: .destructive) { + deleteCourse(course) + } + } message: { course in + Text("确定要移除《\(course.title)》吗?\n移除后学习记录将无法恢复。") + } + + // ✅ 错误提示 + .alert("操作失败", isPresented: $showErrorToast) { + Button("确定", role: .cancel) { } + } message: { + Text(errorMessage) + } + .sheet(item: $courseToEdit) { course in + EditCourseNameSheet( + course: course, + onSave: { newTitle in + handleUpdateCourseName(courseId: course.id, newTitle: newTitle) + } + ) + } + .onAppear { + if userManager.isLoggedIn { + loadCourses() + startProgressPolling() + } + } + .onDisappear { + stopProgressPolling() + } + .onChange(of: userManager.isLoggedIn) { _, isLoggedIn in + if isLoggedIn { + loadCourses() + startProgressPolling() + } else { + joinedCourses = [] + stopProgressPolling() + } + } + // ✅ 监听删除课程通知 + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("DeleteCourse"))) { notification in + if let courseId = notification.userInfo?["courseId"] as? String, + let course = joinedCourses.first(where: { $0.id == courseId }) { + courseToDelete = course + showDeleteAlert = true + } + } + // ✅ Toast 提示 + .overlay(alignment: .trailing) { + if showSuccessToast { + RightSideToast( + title: toastMessage, + type: .success, + onTap: { + withAnimation { + showSuccessToast = false + } + } + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .padding(.top, 100) + .padding(.trailing, 0) + } + } + } + + // MARK: - UI Components + + /// 技能页游客模式:与「我的」页同款样式(锁 + 文案 + 立即登录),顶部对齐 + private var growthGuestPromptCard: some View { + VStack(spacing: 20) { + Image(systemName: "lock.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.inkSecondary.opacity(0.3)) + + Text("登录后创建属于你的学习内容") + .font(.subheadline) + .foregroundColor(.inkSecondary) + + Button(action: { showLoginSheet = true }) { + Text("立即登录") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background(Color.brandVital) + .cornerRadius(24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, 40) + .padding(.bottom, 60) + } + + @ViewBuilder + private var courseGridView: some View { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(joinedCourses) { course in + courseCardView(course: course) + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 16) + } + + @ViewBuilder + private func courseCardView(course: JoinedCourse) -> some View { + Button { + // ✅ 创建中的课程不跳转 + if course.isGeneratingStatus || course.isGenerationFailed { + // 生成中或生成失败,不跳转 + return + } else { + // 正常课程,跳转到课程详情页 + navStore.growthPath.append(CourseNavigation.map(courseId: course.id)) + } + } label: { + ProfileCourseCard(course: course) + } + .modifier(CourseContextMenuModifier( + course: course, + userManager: userManager, + onViewDetails: { + // ✅ 创建中的课程不跳转 + if course.isGeneratingStatus || course.isGenerationFailed { + return + } + navStore.growthPath.append(CourseNavigation.map(courseId: course.id)) + }, + onEditName: { + self.courseToEdit = course + }, + onDelete: { + self.courseToDelete = course + self.showDeleteAlert = true + } + )) + } + + // MARK: - 业务逻辑 + + func loadCourses() { + isLoadingCourses = true + Task { + do { + // ✅ 同时加载已加入课程和所有课程(用于判断 isPortrait) + async let joinedTask = CourseService.shared.fetchJoinedCourses() + async let allCoursesTask = CourseService.shared.getCourses() + + let joined = try await joinedTask + let allCourses = try await allCoursesTask + + await MainActor.run { + self.joinedCourses = joined + self.courses = allCourses + self.isLoadingCourses = false + + // ✅ 重新启动进度轮询(如果有创建中的课程) + self.startProgressPolling() + } + + // ✅ 为没有封面图的课程生成封面 + await generateMissingCovers(for: joined) + } catch { + print("❌ [GrowthView] 加载课程失败: \(error.localizedDescription)") + await MainActor.run { + self.isLoadingCourses = false + } + } + } + } + + // ✅ 为没有封面图的课程生成封面 + private func generateMissingCovers(for courses: [JoinedCourse]) async { + // 找出所有没有封面图的课程 + let coursesWithoutCover = courses.filter { course in + course.coverImage == nil || course.coverImage?.isEmpty == true + } + + guard !coursesWithoutCover.isEmpty else { + return + } + + // 批量生成封面(并发执行) + await withTaskGroup(of: Void.self) { group in + for course in coursesWithoutCover { + group.addTask { + do { + let coverImage = try await CourseService.shared.generateCourseCover(courseId: course.id) + print("✅ [GrowthView] 课程封面生成成功: \(course.id), coverImage: \(coverImage)") + } catch { + print("❌ [GrowthView] 生成课程封面失败: \(course.id), error: \(error.localizedDescription)") + } + } + } + } + + // 所有封面生成完成后,重新加载课程列表 + do { + let updated = try await CourseService.shared.fetchJoinedCourses() + await MainActor.run { + self.joinedCourses = updated + } + } catch { + print("❌ [GrowthView] 重新加载课程失败: \(error.localizedDescription)") + } + } + + // ✅ AI 停止生成相关方法已删除 + + // ✅ 更新课程名称 + private func handleUpdateCourseName(courseId: String, newTitle: String) { + // ✅ 权限检查:确保只有课程创建者才能修改 + guard let currentUserId = userManager.currentUser?.id, + let course = joinedCourses.first(where: { $0.id == courseId }), + let createdBy = course.createdBy, + createdBy == currentUserId else { + // 权限不足,不执行修改 + print("❌ [GrowthView] 权限不足:只有课程创建者才能修改课程名称") + self.errorMessage = "只有课程创建者才能修改课程名称" + self.showErrorToast = true + return + } + + // 乐观更新:立即更新本地显示 + if let index = joinedCourses.firstIndex(where: { $0.id == courseId }) { + let oldCourse = joinedCourses[index] + // 创建更新后的课程对象(由于 title 是 let,需要重新创建) + let updatedCourse = JoinedCourse( + id: oldCourse.id, + title: newTitle, + description: oldCourse.description, + coverImage: oldCourse.coverImage, + type: oldCourse.type, + isPortrait: oldCourse.isPortrait, + status: oldCourse.status, + progress: oldCourse.progress, + totalNodes: oldCourse.totalNodes, + completedNodes: oldCourse.completedNodes, + lastOpenedAt: oldCourse.lastOpenedAt, + joinedAt: oldCourse.joinedAt, + createdBy: oldCourse.createdBy + ) + + withAnimation { + joinedCourses[index] = updatedCourse + } + } + + // 调用后端 API + Task { + do { + try await CourseService.shared.updateCourseTitle(courseId: courseId, title: newTitle) + + // 成功后重新加载以确保数据同步 + await MainActor.run { + Task { + do { + let updated = try await CourseService.shared.fetchJoinedCourses() + await MainActor.run { + self.joinedCourses = updated + } + } catch { + print("❌ [GrowthView] 重新加载课程失败: \(error.localizedDescription)") + } + } + + // 关闭 Sheet + self.courseToEdit = nil + } + } catch { + print("❌ [GrowthView] 更新课程名称失败: \(error.localizedDescription)") + + // 失败时回滚本地更新 + await MainActor.run { + // 重新加载原始数据 + Task { + do { + let updated = try await CourseService.shared.fetchJoinedCourses() + await MainActor.run { + self.joinedCourses = updated + } + } catch { + print("❌ [GrowthView] 重新加载课程失败: \(error.localizedDescription)") + } + } + + self.errorMessage = "更新失败: \(error.localizedDescription)" + self.showErrorToast = true + } + } + } + } + + // ✅ 新的删除逻辑(基于 JoinedCourse 对象,支持回滚) + private func deleteCourse(_ course: JoinedCourse) { + // 1. 记录原始索引 + guard let originalIndex = joinedCourses.firstIndex(where: { $0.id == course.id }) else { return } + + // 2. 乐观更新:立即移除 + _ = withAnimation { + joinedCourses.remove(at: originalIndex) + } + + // 3. 调用后端 + Task { + do { + try await CourseService.shared.removeCourse(courseId: course.id) + // 成功:无需操作 + } catch { + print("删除失败: \(error)") + + // 4. 失败回滚 + await MainActor.run { + withAnimation { + // ✅ 使用安全索引逻辑 + let safeIndex = min(originalIndex, joinedCourses.count) + joinedCourses.insert(course, at: safeIndex) + } + // 显示错误 + self.errorMessage = "删除失败,请检查网络" + self.showErrorToast = true + } + } + self.courseToDelete = nil + } + } + + // MARK: - 进度轮询(用于更新列表中创建中课程的进度) + + /// 开始进度轮询 + private func startProgressPolling() { + // 停止旧的定时器 + stopProgressPolling() + + // 检查是否有创建中的课程 + let generatingCourses = joinedCourses.filter { $0.isGeneratingStatus } + guard !generatingCourses.isEmpty else { return } + + // 每3秒轮询一次 + // 注意:GrowthView 是 struct,不能使用 weak,但 Timer 会在 onDisappear 时被清理 + progressUpdateTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in + Task { @MainActor in + // 通过 NotificationCenter 或直接调用,避免循环引用 + // 由于 Timer 会在 onDisappear 时被清理,这里不需要 weak + await self.updateGeneratingCoursesProgress() + } + } + } + + /// 停止进度轮询 + private func stopProgressPolling() { + progressUpdateTimer?.invalidate() + progressUpdateTimer = nil + } + + /// 更新创建中课程的进度 + @MainActor + private func updateGeneratingCoursesProgress() async { + // 找出所有创建中的课程(在主线程上读取) + let generatingCourses = joinedCourses.filter { $0.isGeneratingStatus } + guard !generatingCourses.isEmpty else { + stopProgressPolling() + return + } + + // 并发更新每个课程的进度 + await withTaskGroup(of: Void.self) { group in + for course in generatingCourses { + guard let taskId = course.generationTaskId else { continue } + + group.addTask { + do { + let statusData = try await AICourseService.shared.getTaskStatus(taskId: taskId) + + await MainActor.run { + // 更新对应课程的进度 + if let index = self.joinedCourses.firstIndex(where: { $0.id == course.id }) { + let oldCourse = self.joinedCourses[index] + + // 创建更新后的课程对象 + let updatedCourse = JoinedCourse( + id: oldCourse.id, + title: statusData.courseTitle ?? oldCourse.title, + description: oldCourse.description, + coverImage: oldCourse.coverImage, + themeColor: oldCourse.themeColor, + watermarkIcon: oldCourse.watermarkIcon, + type: oldCourse.type, + isPortrait: oldCourse.isPortrait, + status: statusData.status == "completed" ? "completed" : + statusData.status == "failed" ? "failed" : oldCourse.status, + progress: statusData.progress, + totalNodes: oldCourse.totalNodes, + completedNodes: oldCourse.completedNodes, + lastOpenedAt: oldCourse.lastOpenedAt, + joinedAt: oldCourse.joinedAt, + createdBy: oldCourse.createdBy, + isGenerating: statusData.status != "completed" && statusData.status != "failed", + generationStatus: statusData.status, + generationProgress: statusData.progress, + generationTaskId: oldCourse.generationTaskId + ) + + self.joinedCourses[index] = updatedCourse + + // 如果已完成或失败,重新加载完整列表以确保数据同步 + if statusData.status == "completed" || statusData.status == "failed" { + Task { + self.loadCourses() + } + } + } + } + } catch { + print("❌ [GrowthView] 更新课程进度失败: \(course.id), error: \(error)") + } + } + } + } + + // 检查是否还有创建中的课程,如果没有则停止轮询 + await MainActor.run { + let stillGenerating = self.joinedCourses.contains { $0.isGeneratingStatus } + if !stillGenerating { + self.stopProgressPolling() + } + } + } +} + +// MARK: - 右侧Toast组件 +struct RightSideToast: View { + let title: String + let type: ToastType + var onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + Image(systemName: type == .error ? "exclamationmark.circle.fill" : "checkmark.circle.fill") + .foregroundColor(type == .error ? .red : .brandVital) + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + Text(type == .error ? "生成失败" : "生成完成") + .font(.caption) + .foregroundColor(.inkSecondary) + Text(title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.inkPrimary) + .lineLimit(1) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white) + .cornerRadius(24, corners: [.topLeft, .bottomLeft]) + .shadow(color: Color.black.opacity(0.1), radius: 8, x: -2, y: 2) + } + .buttonStyle(.plain) + } +} + +// MARK: - Course Context Menu Modifier +// ✅ 提取 Context Menu 逻辑,避免表达式过于复杂 +struct CourseContextMenuModifier: ViewModifier { + let course: JoinedCourse + let userManager: UserManager + let onViewDetails: () -> Void + let onEditName: () -> Void + let onDelete: () -> Void + + @ViewBuilder + func body(content: Content) -> some View { + // ✅ 所有课程都显示 contextMenu,但生成中的课程只显示"移除课程" + content + .contextMenu { + contextMenuContent + } + } + + @ViewBuilder + private var contextMenuContent: some View { + // ✅ 生成中的课程只显示"移除课程" + if course.isGeneratingStatus { + Button(role: .destructive) { + onDelete() + } label: { + Label("移除课程", systemImage: "trash") + } + } else { + // 正常课程显示完整菜单 + Button { + onViewDetails() + } label: { + Label("查看详情", systemImage: "doc.text") + } + + // ✅ 如果是用户创建的课程,显示修改课程名称选项 + if let currentUserId = userManager.currentUser?.id, + let createdBy = course.createdBy, + createdBy == currentUserId { + Divider() + + Button { + onEditName() + } label: { + Label("修改课程名称", systemImage: "pencil") + } + } + + Divider() + + Button(role: .destructive) { + onDelete() + } label: { + Label("移除课程", systemImage: "trash") + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/NoteTree/NewNoteView.swift b/ios/WildGrowth/WildGrowth/Views/NoteTree/NewNoteView.swift new file mode 100644 index 0000000..86027e9 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/NoteTree/NewNoteView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +struct NewNoteView: View { + let notebookId: String + let parentId: String? // 可选:父笔记 ID(用于创建子笔记) + let onNoteCreated: (() -> Void)? // 可选:笔记创建成功后的回调 + @Environment(\.dismiss) var dismiss + + @State private var content: String = "" + @State private var isSaving = false + @State private var showError = false + @State private var errorMessage = "" + + // iPad 适配 + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + NavigationStack { + ZStack { + Color.bgPaper.ignoresSafeArea() + + VStack(spacing: 0) { + // 输入区域 + VStack(alignment: .leading, spacing: 16) { + Text("记录你的想法") + .font(.headline) + .foregroundColor(.inkPrimary) + .padding(.horizontal, 20) + .padding(.top, 20) + + // 文本输入框 + ZStack(alignment: .topLeading) { + if content.isEmpty { + Text("输入你的想法...") + .foregroundColor(.inkSecondary.opacity(0.5)) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + TextEditor(text: $content) + .font(.body) + .foregroundColor(.inkPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 200) + .background(Color.bgWhite) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.brandVital.opacity(0.15), lineWidth: 1) + ) + } + .padding(.horizontal, 20) + } + + Spacer() + } + } + .navigationTitle("新建笔记") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("保存") { + saveNote() + } + .disabled(content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) + .fontWeight(.semibold) + } + } + .alert("保存失败", isPresented: $showError) { + Button("确定", role: .cancel) {} + } message: { + Text(errorMessage) + } + } + .modifier(iPadSheetAdaptationModifier()) + } + + private func saveNote() { + guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + isSaving = true + + Task { + do { + let request = CreateNoteRequest( + notebookId: notebookId, + parentId: parentId, + order: nil, // 后端会自动计算 + level: nil, // 后端会自动计算 + courseId: nil, + nodeId: nil, + startIndex: nil, + length: nil, + type: "thought", + quotedText: nil, + content: content.trimmingCharacters(in: .whitespacesAndNewlines), + style: nil + ) + + _ = try await NoteService.shared.createNote(request) + + await MainActor.run { + isSaving = false + // 先调用回调,再关闭 + onNoteCreated?() + dismiss() + } + } catch { + await MainActor.run { + isSaving = false + errorMessage = error.localizedDescription + showError = true + } + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/NoteTree/NoteTreeRow.swift b/ios/WildGrowth/WildGrowth/Views/NoteTree/NoteTreeRow.swift new file mode 100644 index 0000000..14fc3dc --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/NoteTree/NoteTreeRow.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct NoteTreeRow: View { + // 数据 + let note: Note + + // 交互回调 + let onJumpToOriginal: () -> Void // 点击跳转 + let onDelete: () -> Void // 上下文菜单调用 + let onEdit: () -> Void // 上下文菜单调用 + + // 环境变量 + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + // 动态缩进 (仅作展示) + private var indentWidth: CGFloat { + horizontalSizeClass == .regular ? 32 : 24 + } + + // 类型判断 + private var isThoughtNote: Bool { + return note.type == .thought + } + + // ✅ 系统笔记功能:判断是否为系统笔记 + private var isSystemNote: Bool { + return note.isSystemNote + } + + // ✅ 系统笔记功能:深灰色文字颜色 + private var textColor: Color { + isSystemNote ? Color.inkSecondary : Color.inkPrimary + } + + // ✅ 系统笔记功能:引用文字颜色 + private var quoteTextColor: Color { + isSystemNote ? Color.inkSecondary.opacity(0.7) : Color.inkSecondary + } + + var body: some View { + HStack(spacing: 0) { + // 1. 物理缩进 (层级体现) + Color.clear + .frame(width: CGFloat(note.level) * indentWidth) + + // 2. 核心卡片 + VStack(alignment: .leading, spacing: 0) { + // 根据类型渲染内容 + if isThoughtNote { + thoughtContentStyle + } else { + highlightContentStyle + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) // 确保卡片撑满宽度 + .background(Color.bgWhite) + .cornerRadius(12) + // DesignSystem 卡片阴影 + .shadow(color: Color.black.opacity(0.06), radius: 8, x: 0, y: 3) + .contentShape(Rectangle()) + // ✅ 交互 1: 点击跳转 + .onTapGesture { + onJumpToOriginal() + } + // ✅ 交互 2: 长按/上下文菜单 (保持界面极简,功能藏在这里) + // ✅ 系统笔记功能:系统笔记不可编辑、不可删除 + .contextMenu { + if !isSystemNote { + if isThoughtNote { + Button(action: onEdit) { + Label("编辑", systemImage: "pencil") + } + } + Button(role: .destructive, action: onDelete) { + Label("删除", systemImage: "trash") + } + } + } + } + .padding(.horizontal, horizontalSizeClass == .regular ? 24 : 16) + .padding(.vertical, 6) + } + + // MARK: - 样式组件 + + // 样式 A: 想法笔记 (Thought > Quote) + private var thoughtContentStyle: some View { + VStack(alignment: .leading, spacing: 6) { // ✅ 间距紧凑 + // 1. 想法 (最大,最醒目) + // ✅ 系统笔记功能:系统笔记使用深灰色 + Text(note.content ?? "") + .font(.system(size: 17, weight: .medium)) // 17pt Medium + .foregroundColor(textColor) // ✅ 系统笔记使用深灰色 + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + // 2. 引用 (最小,灰色,带竖线) + if let quote = note.quotedText { + HStack(spacing: 8) { + // ✅ 灰色竖线修饰 + Rectangle() + .fill(Color.inkSecondary.opacity(0.3)) + .frame(width: 2) + .frame(maxHeight: .infinity) // 撑满高度 + .cornerRadius(1) + + Text(quote) + .font(.system(size: 14)) // 14pt + .foregroundColor(quoteTextColor) // ✅ 系统笔记使用更浅的灰色 + .lineLimit(3) // 限制3行,超过显示省略号 + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) + } + } + } + + // 样式 B: 划线笔记 (Highlight Only) + private var highlightContentStyle: some View { + // ✅ 直接展示内容,无竖线 + // ✅ 系统笔记功能:系统笔记使用深灰色 + Group { + if let quote = note.quotedText { + Text(quote) + .font(.system(size: 16, weight: .regular)) // 16pt (介于想法和引用之间) + .foregroundColor(isSystemNote ? Color.inkSecondary : Color.inkPrimary.opacity(0.85)) // ✅ 系统笔记使用深灰色 + .lineLimit(nil) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("无内容") + .font(.caption) + .foregroundColor(.gray) + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Notebook/NotebookCardView.swift b/ios/WildGrowth/WildGrowth/Views/Notebook/NotebookCardView.swift new file mode 100644 index 0000000..92bccef --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Notebook/NotebookCardView.swift @@ -0,0 +1,114 @@ +import SwiftUI +import Kingfisher +// ✅ 修复:导入APIClient以支持智能URL处理 + +struct NotebookCardView: View { + let notebook: Notebook + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // === 左侧:核心信息 === + VStack(alignment: .leading, spacing: 0) { + // 1. 笔记数量 + HStack(alignment: .lastTextBaseline, spacing: 2) { + Text("\(notebook.noteCount ?? 0)") + .font(.system(size: 36, weight: .semibold)) + .foregroundColor(.inkPrimary) + // ✅ P2 修正: 移除负 padding,避免裁剪 + + Text("个笔记") + .font(.system(size: 14)) + .foregroundColor(.inkPrimary) + .padding(.bottom, 6) + } + + Spacer() + + // 2. 标题 + Text(notebook.title) + .font(.system(size: 15, weight: .regular)) + .foregroundColor(.inkSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + // ✅ P4 修正: 移除底部多余的 Spacer微调 + } + .frame(height: 99) // ✅ P1 修正: 高度同步为 99 (与封面一致) + + Spacer() + + // === 右侧:封面图 === + coverView + } + .padding(16) + .background(Color.bgWhite) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.04), radius: 10, x: 0, y: 2) + } + + // 封面视图 + private var coverView: some View { + Group { + if let coverUrl = notebook.coverImage, !coverUrl.isEmpty { + // ✅ 修复:智能URL处理(支持完整URL和相对路径) + let finalUrl: URL? = { + if coverUrl.hasPrefix("http") { + return URL(string: coverUrl) + } else { + return APIClient.shared.getImageURL(coverUrl) + } + }() + + if let url = finalUrl { + // ✅ 使用 KFImage 与课程封面保持一致 + ZStack { + // 占位视图(确保容器不塌陷) + fallbackCover + + // 网络图片(加载完成后覆盖占位视图) + KFImage(url) + .resizable() + .aspectRatio(contentMode: .fill) + } + } else { + fallbackCover + } + } else { + fallbackCover + } + } + .frame(width: 66, height: 99) // ✅ P1 修正: 调整为 66x99 (精确 2:3 比例) + .cornerRadius(4) + .shadow(color: Color.black.opacity(0.1), radius: 3, x: 1, y: 1) + .clipped() + } + + // 默认封面 + private var fallbackCover: some View { + ZStack { + // 蓝色渐变背景 + LinearGradient( + colors: [Color.brandVital.opacity(0.8), Color.brandVital], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // 大的垂直《符号在中间 + VStack { + Text("《") + .font(.system(size: 48, weight: .light)) + .foregroundColor(.white.opacity(0.4)) + .rotationEffect(.degrees(90)) // 垂直方向 + .padding(.top, 8) + + // 小的《符号在下方 + Text("《") + .font(.system(size: 24, weight: .light)) + .foregroundColor(.white.opacity(0.25)) + .rotationEffect(.degrees(90)) // 垂直方向 + .offset(x: -8, y: -8) // 稍微偏移 + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Notebook/NotebookFormSheet.swift b/ios/WildGrowth/WildGrowth/Views/Notebook/NotebookFormSheet.swift new file mode 100644 index 0000000..9cf32a6 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Notebook/NotebookFormSheet.swift @@ -0,0 +1,118 @@ +import SwiftUI + +struct NotebookFormSheet: View { + @Environment(\.dismiss) var dismiss + + // 模式:创建或编辑 + let mode: FormMode + + enum FormMode { + case create + case edit(Notebook) + } + + @State private var title: String = "" + @State private var description: String = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + + // 回调:成功后刷新列表 + var onSuccess: () -> Void + + var body: some View { + NavigationStack { + Form { + Section { + TextField("笔记本名称", text: $title) + .font(.body) + + TextField("描述 (可选)", text: $description, axis: .vertical) + .lineLimit(3...6) + .font(.body) + } header: { + Text("基本信息") + } footer: { + Text("好的标题能帮你快速找到知识库。") + } + } + .navigationTitle(modeTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + + ToolbarItem(placement: .confirmationAction) { + Button("保存") { + submit() + } + .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting) + .fontWeight(.bold) + .foregroundColor(.brandVital) + } + } + .disabled(isSubmitting) + .overlay { + if isSubmitting { + ProgressView() + } + } + .alert("发生错误", isPresented: Binding(get: { errorMessage != nil }, set: { _ in errorMessage = nil })) { + Button("确定", role: .cancel) { } + } message: { + Text(errorMessage ?? "") + } + } + .onAppear { + if case .edit(let notebook) = mode { + title = notebook.title + description = notebook.description ?? "" + } + } + .modifier(iPadSheetAdaptationModifier()) + } + + private var modeTitle: String { + switch mode { + case .create: return "新建笔记本" + case .edit: return "编辑笔记本" + } + } + + private func submit() { + isSubmitting = true + + Task { + do { + if case .edit(let notebook) = mode { + // 更新 + _ = try await NotebookService.shared.updateNotebook( + id: notebook.id, + title: title, + description: description.isEmpty ? nil : description, + coverImage: nil + ) + } else { + // 创建 + _ = try await NotebookService.shared.createNotebook( + title: title, + description: description.isEmpty ? nil : description, + coverImage: nil + ) + AnalyticsManager.shared.track("notebook_create") + } + + await MainActor.run { + isSubmitting = false + dismiss() + onSuccess() + } + } catch { + await MainActor.run { + isSubmitting = false + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/PersonaSelectionView.swift b/ios/WildGrowth/WildGrowth/Views/PersonaSelectionView.swift new file mode 100644 index 0000000..7be89ca --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/PersonaSelectionView.swift @@ -0,0 +1,262 @@ +import SwiftUI + +struct PersonaSelectionView: View { + // MARK: - 接收参数 + let sourceText: String + let sourceType: SourceType + let fileName: String? + let thinkingFlowText: String + let parentCourseId: String? + + // 接收父视图的 Path 绑定 + @Binding var navigationPath: NavigationPath + + // MARK: - 本地状态 + /// 默认选第一个选项(onAppear 会根据流程类型覆盖) + @State private var selectedPersona: PersonaType = .directTestLiteOutline + + private var initialPersona: PersonaType { + switch sourceType { + case .direct: + return .directTestLiteOutline + case .document: + return .textParseXiaolin + case .continue: + return .continueCourseXiaolin + } + } + @State private var isLoading = false + @State private var showError = false + @State private var errorMessage = "" + + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + // 1. 全局背景 + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + + VStack(spacing: 24) { + + // 2. 标题区 + VStack(alignment: .leading, spacing: 8) { + Text("选择讲解老师") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.primary) + .padding(.top, 20) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + + // 3. 老师卡片列表 + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + // 直接生成流程 + if sourceType == .direct { + CleanPersonaCard( + title: "小红学姐", + slogan: "万粉博主,使用钩子教学法,适合重度学习困难者,生成10张卡片", + isSelected: selectedPersona == .directTestLite + ) { selectedPersona = .directTestLite } + CleanPersonaCard( + title: "林老师(推荐)", + slogan: "温柔有耐心,擅长用大白话和故事的方式讲解,生成10卡片", + isSelected: selectedPersona == .directTestLiteOutline + ) { selectedPersona = .directTestLiteOutline } + CleanPersonaCard( + title: "丁老师", + slogan: "擅长有条理地讲解内容,生成20张卡片", + isSelected: selectedPersona == .directTestLiteSummary + ) { selectedPersona = .directTestLiteSummary } + } else if sourceType == .document { + // 文本解析流程 + CleanPersonaCard( + title: "小红学姐", + slogan: "万粉知识博主,使用钩子教学法,适合重度学习困难者", + isSelected: selectedPersona == .textParseXiaohongshu + ) { selectedPersona = .textParseXiaohongshu } + + CleanPersonaCard( + title: "林老师(推荐)", + slogan: "极其温柔有耐心,擅长用大白话和故事的方式讲解", + isSelected: selectedPersona == .textParseXiaolin + ) { selectedPersona = .textParseXiaolin } + + CleanPersonaCard( + title: "丁老师", + slogan: "擅长有条理地讲解内容", + isSelected: selectedPersona == .textParseDouyin + ) { selectedPersona = .textParseDouyin } + } else { + // 续旧课流程 + CleanPersonaCard( + title: "小红学姐", + slogan: "万粉博主,使用钩子教学法,适合重度学习困难者,生成10张卡片", + isSelected: selectedPersona == .continueCourseXiaohongshu + ) { selectedPersona = .continueCourseXiaohongshu } + + CleanPersonaCard( + title: "林老师(推荐)", + slogan: "温柔有耐心,擅长用大白话和故事的方式讲解,生成10卡片", + isSelected: selectedPersona == .continueCourseXiaolin + ) { selectedPersona = .continueCourseXiaolin } + + CleanPersonaCard( + title: "丁老师", + slogan: "擅长有条理地讲解内容,生成20张卡片", + isSelected: selectedPersona == .continueCourseDouyin + ) { selectedPersona = .continueCourseDouyin } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 100) + .padding(.top, 2) + } + .onAppear { + // 根据流程类型设置默认选中 + selectedPersona = initialPersona + } + } + + // 4. 底部悬浮按钮 + VStack { + Spacer() + Button { + handleStartGeneration() + } label: { + HStack(spacing: 0) { + if isLoading { + ProgressView() + .tint(.white) + .padding(.trailing, 8) + } + + Text("创建内容") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(Color.brandVital) + .foregroundColor(.white) + .clipShape(Capsule()) + .shadow(color: Color.brandVital.opacity(0.4), radius: 10, y: 5) + } + .disabled(isLoading) + .padding(.horizontal, 32) + .padding(.bottom, 20) + } + } + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.secondary) + } + } + } + .alert("创建失败", isPresented: $showError) { + Button("确定", role: .cancel) { } + } message: { Text(errorMessage) } + } + + // MARK: - Logic + private func handleStartGeneration() { + isLoading = true + + Task { + do { + let (taskId, courseId) = try await AICourseService.shared.createCourse( + sourceText: sourceText, + sourceType: sourceType, + persona: selectedPersona, + parentCourseId: parentCourseId + ) + + await MainActor.run { + isLoading = false + navigationPath.append(CreateCourseRoute.progress( + taskId: taskId, + courseId: courseId, + thinkingFlowText: thinkingFlowText + )) + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = error.localizedDescription + showError = true + } + } + } + } +} + +// MARK: - 极简卡片组件 (Clean Persona Card) +struct CleanPersonaCard: View { + let title: String + let slogan: String + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(isSelected ? .brandVital : .primary) + + Text(slogan) + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.secondary) + .fontDesign(.serif) + .italic() + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + Spacer() + + // 右侧状态指示 + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundColor(.brandVital) + .transition(.scale.combined(with: .opacity)) + } else { + Image(systemName: "circle") + .font(.title2) + .foregroundColor(.gray.opacity(0.2)) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 20) + .background( + ZStack { + Color.white + if isSelected { + Color.brandVital.opacity(0.04) + } + } + ) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .strokeBorder(isSelected ? Color.brandVital : Color.clear, lineWidth: 2) + ) + .shadow( + color: Color.black.opacity(isSelected ? 0.02 : 0.06), + radius: isSelected ? 4 : 8, + x: 0, + y: isSelected ? 2 : 4 + ) + .scaleEffect(isSelected ? 1.02 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) + } + .buttonStyle(.plain) + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Profile/AvatarPicker.swift b/ios/WildGrowth/WildGrowth/Views/Profile/AvatarPicker.swift new file mode 100644 index 0000000..dc5a969 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Profile/AvatarPicker.swift @@ -0,0 +1,75 @@ +import SwiftUI +import PhotosUI + +struct AvatarPicker: View { + @Binding var isPresented: Bool + let onImageSelected: (UIImage) -> Void + + @State private var selectedItem: PhotosPickerItem? = nil + @State private var isLoading = false // ✅ 加载状态 + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Image(systemName: "photo.stack") + .font(.system(size: 60)) + .foregroundColor(.brandVital) + .padding(.top, 40) + + Text("更换头像") + .font(.title2.bold()) + + Text("从相册选择一张图片作为你的赛博学习证头像") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer() + + if isLoading { + ProgressView() + .padding() + } + + PhotosPicker(selection: $selectedItem, matching: .images) { + Text("选择照片") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color.brandVital) + .cornerRadius(25) + .padding(.horizontal) + } + .onChange(of: selectedItem) { _, newItem in + guard let item = newItem else { return } + isLoading = true + Task { + do { + if let data = try await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + await MainActor.run { + onImageSelected(image) + isLoading = false + isPresented = false + } + } else { + await MainActor.run { isLoading = false } + } + } catch { + await MainActor.run { isLoading = false } + } + } + } + + Button("取消") { + isPresented = false + } + .padding(.bottom, 20) + } + .navigationBarHidden(true) + } + .presentationDetents([.medium]) + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Profile/CyberLearningIDCard.swift b/ios/WildGrowth/WildGrowth/Views/Profile/CyberLearningIDCard.swift new file mode 100644 index 0000000..be0ac34 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Profile/CyberLearningIDCard.swift @@ -0,0 +1,259 @@ +import SwiftUI +import Kingfisher + +struct CyberLearningIDCard: View { + // MARK: - Properties + let avatar: String? + /// 头像更新后递增,用于 URL 缓存破坏,实时显示最新上传头像 + var avatarCacheBust: Int = 0 + let nickname: String + let digitalId: String? + let userId: String? + + // MARK: - Callbacks + // ✅ 必须修正:保持原有独立接口,不破坏 ProfileView 现有逻辑 + let onAvatarTap: () -> Void + let onNameTap: () -> Void + let onCopyID: () -> Void + + // MARK: - Computed + private var effectiveDigitalID: String { + guard let userId = userId else { return "-----" } + return DigitalIDGenerator.getDigitalID(digitalId: digitalId, userId: userId) + } + + // MARK: - Body + var body: some View { + ZStack { + // 1. 卡片背景:保持深邃,去掉杂色光晕 + ZStack { + // 底层:品牌色深空渐变 + RoundedRectangle(cornerRadius: 20) + .fill( + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.05, blue: 0.2), // 深午夜蓝 + Color.brandVital.opacity(0.9), // 品牌蓝 + Color(red: 0.1, green: 0.0, blue: 0.2) // 深紫暗部 + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + // 第二层:电路纹理 (白色低透明度) + CircuitPatternOverlay() + .opacity(0.08) + .mask(RoundedRectangle(cornerRadius: 20)) + + // 第三层:整体光感 (纯白微光) + RoundedRectangle(cornerRadius: 20) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.1), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .blendMode(.overlay) + } + .shadow(color: Color.black.opacity(0.4), radius: 15, x: 0, y: 10) + + // 2. 装饰:纯白极细描边 + RoundedRectangle(cornerRadius: 20) + .strokeBorder( + LinearGradient( + colors: [ + Color.white.opacity(0.4), // 高光 + Color.white.opacity(0.1), // 过渡 + Color.white.opacity(0.05) // 消失 + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.8 // 极细,像激光切割 + ) + + // 3. 内容布局 + HStack(spacing: 20) { + // 头像 + Button(action: onAvatarTap) { + ZStack { + // 外层装饰环 (纯白) + Circle() + .stroke( + LinearGradient( + colors: [Color.white.opacity(0.4), Color.white.opacity(0)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .frame(width: 60, height: 60) + + if let avatarStr = avatar, + let baseURL = APIClient.shared.getImageURL(avatarStr) { + let urlWithBust = URL(string: baseURL.absoluteString + (baseURL.query != nil ? "&" : "?") + "t=\(avatarCacheBust)") + KFImage(urlWithBust ?? baseURL) + .placeholder { defaultAvatar } + .setProcessor(DefaultImageProcessor.default) + .cacheMemoryOnly() + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.9), lineWidth: 1.5)) + .id(avatarStr + "-\(avatarCacheBust)") + } else { + defaultAvatar + } + } + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 8) { + // 昵称 + Button(action: onNameTap) { + Text(nickname.isEmpty ? "点击设置昵称" : nickname) + .font(.h2()) + .foregroundColor(.white) + // 只有极其微弱的阴影,保持干净 + .shadow(color: Color.black.opacity(0.2), radius: 2, x: 0, y: 1) + } + .buttonStyle(.plain) + + // ID 行:纯净数据风格 + HStack(spacing: 8) { + Text("ID:") + .font(.label()) + .foregroundColor(.white.opacity(0.6)) // 降低透明度即可,不要颜色 + + Text(effectiveDigitalID) + .font(.statDigitalID()) + .foregroundColor(.white) + .kerning(1.5) + .onLongPressGesture { + UIPasteboard.general.string = effectiveDigitalID + onCopyID() + } + } + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(0.2)) // 深色底板 + // 纯白微弱描边 + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + ) + } + + Spacer() + } + .padding(.leading, 24) + .padding(.trailing, 20) + .padding(.vertical, 24) + + // 4. 右下角 "修改" 按钮 (极简幽灵态) + VStack { + Spacer() + HStack { + Spacer() + Button(action: onNameTap) { + ZStack { + // 几乎隐形 + Circle() + .fill(Color.black.opacity(0.1)) + .frame(width: 32, height: 32) + + // 极细白线 + Circle() + .stroke(Color.white.opacity(0.1), lineWidth: 0.5) + .frame(width: 32, height: 32) + + // 图标 + Image(systemName: "square.and.pencil") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } + } + .padding(16) + } + } + } + .frame(height: 130) + // 右上角数字印记 (保留) + .overlay(alignment: .topTrailing) { + DigitalStampView() + .offset(x: -16, y: 16) + } + } + + private var defaultAvatar: some View { + Circle() + .fill(Color.white.opacity(0.1)) + .frame(width: 50, height: 50) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: 24)) + .foregroundColor(.white.opacity(0.6)) + ) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + } +} + +// MARK: - 辅助组件 + +struct DigitalStampView: View { + var body: some View { + HStack(spacing: 6) { + // 纯白闪烁点 + Circle() + .fill(Color.white) + .frame(width: 6, height: 6) + .shadow(color: Color.white.opacity(0.8), radius: 4, x: 0, y: 0) // 白光晕 + + Text("电子证") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white) + .kerning(2) + .shadow(color: Color.white.opacity(0.3), radius: 2, x: 0, y: 0) + } + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background( + Capsule() + .fill(Color.black.opacity(0.3)) // 深色底 + .overlay( + Capsule() + // 纯白渐变描边 + .stroke( + LinearGradient( + colors: [Color.white.opacity(0.5), Color.white.opacity(0.1)], + startPoint: .leading, + endPoint: .trailing + ), + lineWidth: 1 + ) + ) + ) + } +} + +struct CircuitPatternOverlay: View { + var body: some View { + GeometryReader { geo in + Path { path in + let w = geo.size.width + let h = geo.size.height + path.move(to: CGPoint(x: w * 0.1, y: 0)) + path.addLine(to: CGPoint(x: w * 0.1, y: h * 0.3)) + path.addLine(to: CGPoint(x: w * 0.2, y: h * 0.4)) + path.move(to: CGPoint(x: w * 0.8, y: h)) + path.addLine(to: CGPoint(x: w * 0.8, y: h * 0.6)) + path.addLine(to: CGPoint(x: w * 0.7, y: h * 0.5)) + path.addEllipse(in: CGRect(x: w * 0.2 - 3, y: h * 0.4 - 3, width: 6, height: 6)) + } + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Profile/EditProfileSheet.swift b/ios/WildGrowth/WildGrowth/Views/Profile/EditProfileSheet.swift new file mode 100644 index 0000000..f12ea60 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Profile/EditProfileSheet.swift @@ -0,0 +1,174 @@ +import SwiftUI +import PhotosUI +import Kingfisher + +struct EditProfileSheet: View { + // MARK: - Properties + @Environment(\.dismiss) private var dismiss + + // 初始数据 + let currentNickname: String + let currentAvatarUrl: String + + // 状态管理 + @State private var tempNickname: String + @State private var selectedItem: PhotosPickerItem? + @State private var selectedAvatarImage: UIImage? + @State private var isSaving = false + + // 回调 + var onSaveAction: (UIImage?, String) async -> Void + + init(currentNickname: String, currentAvatarUrl: String, onSaveAction: @escaping (UIImage?, String) async -> Void) { + self.currentNickname = currentNickname + self.currentAvatarUrl = currentAvatarUrl + self._tempNickname = State(initialValue: currentNickname) + self.onSaveAction = onSaveAction + } + + // MARK: - Body + var body: some View { + NavigationView { + VStack(spacing: 24) { + // 1. 头像编辑区域 + VStack(spacing: 12) { + ZStack { + if let image = selectedAvatarImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } else { + // ✅ 修正:使用 APIClient 处理 URL + if let url = APIClient.shared.getImageURL(currentAvatarUrl) { + KFImage(url) + .resizable() + .placeholder { + Circle() + .fill(Color.bgPaper) + .frame(width: 100, height: 100) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: 40)) + .foregroundColor(.inkSecondary) + ) + } + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } else { + Circle() + .fill(Color.bgPaper) + .frame(width: 100, height: 100) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: 40)) + .foregroundColor(.inkSecondary) + ) + } + } + + // 编辑角标 + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: "camera.fill") + .font(.system(size: 12)) + .foregroundColor(.white) + .padding(6) + .background(Color.brandVital) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white, lineWidth: 2)) + } + } + .frame(width: 100, height: 100) + } + + // 照片选择器 + PhotosPicker(selection: $selectedItem, matching: .images) { + Text("更换头像") + .font(.h3()) + .foregroundColor(.brandVital) + } + .onChange(of: selectedItem) { _, newItem in + Task { + if let data = try? await newItem?.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + await MainActor.run { + selectedAvatarImage = image + } + } + } + } + } + .padding(.top, 20) + + // 2. 昵称输入框 + VStack(alignment: .leading, spacing: 8) { + Text("昵称") + .font(.label()) + .foregroundColor(.inkSecondary) + .padding(.leading, 4) + + TextField("请输入昵称", text: $tempNickname) + .font(.body()) + .padding() + .background(Color.bgPaper) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.inkSecondary.opacity(0.1), lineWidth: 1) + ) + } + .padding(.horizontal) + + Spacer() + + // 3. 保存按钮 + Button { + Task { + isSaving = true + await onSaveAction(selectedAvatarImage, tempNickname) + isSaving = false + dismiss() + } + } label: { + HStack { + if isSaving { + ProgressView().tint(.white) + } else { + Text("保存修改") + .font(.buttonPrimary()) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(hasChanges ? Color.brandVital : Color.brandVital.opacity(0.5)) + .foregroundColor(.white) + .cornerRadius(25) + } + .disabled(!hasChanges || isSaving) + .padding(.horizontal) + .padding(.bottom, 20) + } + .navigationTitle("编辑资料") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("取消") { + dismiss() + } + .foregroundColor(.inkPrimary) + } + } + } + .navigationViewStyle(.stack) + } + + // 检查是否有变更 + var hasChanges: Bool { + return selectedAvatarImage != nil || tempNickname != currentNickname + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Profile/ProfileNoteListView.swift b/ios/WildGrowth/WildGrowth/Views/Profile/ProfileNoteListView.swift new file mode 100644 index 0000000..035bd72 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Profile/ProfileNoteListView.swift @@ -0,0 +1,136 @@ +import SwiftUI +import Kingfisher + +struct ProfileNoteListView: View { + @State private var notebooks: [Notebook] = [] + @State private var isLoading = false + @EnvironmentObject var navStore: NavigationStore + + var body: some View { + VStack(spacing: 12) { + if isLoading { + ProgressView().padding(.top, 40) + } else if notebooks.isEmpty { + // ✅ 空状态优化 + VStack(spacing: 8) { + Image(systemName: "note.text") + .font(.system(size: 32)) + .foregroundColor(.inkSecondary.opacity(0.3)) + Text("暂无笔记") + .font(.system(size: 14)) + .foregroundColor(.inkSecondary) + } + .padding(.top, 40) + } else { + ForEach(notebooks) { notebook in + Button { + navStore.growthPath.append(notebook) + } label: { + HStack(alignment: .center, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + // 1. 数量行 + HStack(alignment: .lastTextBaseline, spacing: 4) { + Text("\(notebook.noteCount ?? 0)") + .font(.statDisplayMedium()) // 24pt Rounded + .foregroundColor(.inkPrimary) + + Text("个笔记") + .font(.label()) // 13pt + .foregroundColor(.inkPrimary) + } + + // 2. 标题行 + Text(notebook.title) + .font(.h3()) // 16pt Medium + .foregroundColor(.inkSecondary) + .lineLimit(1) + } + + Spacer() + + // 3. 封面图区域 (完全还原逻辑) + ZStack { + if let coverUrl = notebook.coverImage, !coverUrl.isEmpty { + // 智能 URL 处理 + if coverUrl.hasPrefix("http"), let url = URL(string: coverUrl) { + KFImage(url) + .resizable() + .placeholder { + Rectangle().fill(Color.gray.opacity(0.1)) + } + .aspectRatio(contentMode: .fill) + .frame(width: 48, height: 68) + .cornerRadius(4) + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 1, y: 1) + } else if let url = APIClient.shared.getImageURL(coverUrl) { + KFImage(url) + .resizable() + .placeholder { + Rectangle().fill(Color.gray.opacity(0.1)) + } + .aspectRatio(contentMode: .fill) + .frame(width: 48, height: 68) + .cornerRadius(4) + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 1, y: 1) + } else { + Rectangle().fill(Color.gray.opacity(0.1)) + .frame(width: 48, height: 68) + .cornerRadius(4) + } + } else { + Rectangle().fill(Color.gray.opacity(0.1)) + .frame(width: 48, height: 68) + .cornerRadius(4) + } + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .frame(height: 100) + .background(Color.bgWhite) // ✅ 确认使用 bgWhite + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.02), radius: 4, y: 1) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 20) + // ✅ 与计划Tab保持一致:顶部间距设为 12 + .padding(.top, 12) + .padding(.bottom, 20) + .onAppear { + loadData() + } + } + + // 默认封面组件 + private var defaultCover: some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color.bgPaper) + .frame(width: 48, height: 68) + .overlay( + Image(systemName: "book.closed") + .font(.system(size: 20)) + .foregroundColor(.inkSecondary.opacity(0.2)) + ) + .border(Color.inkSecondary.opacity(0.05), width: 0.5) + } + + func loadData() { + isLoading = true + Task { + do { + let items = try await NotebookService.shared.getNotebooks() + await MainActor.run { + self.notebooks = items + self.isLoading = false + } + } catch { + await MainActor.run { + self.isLoading = false + } + } + } + } +} diff --git a/ios/WildGrowth/WildGrowth/Views/Profile/ProfileTabView.swift b/ios/WildGrowth/WildGrowth/Views/Profile/ProfileTabView.swift new file mode 100644 index 0000000..1c7c902 --- /dev/null +++ b/ios/WildGrowth/WildGrowth/Views/Profile/ProfileTabView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct ProfileTabView: View { + @Binding var selectedTab: Int + let tabs = ["计划", "笔记"] + // ✅ 正确声明命名空间 + @Namespace private var namespace + + var body: some View { + VStack(spacing: 0) { + HStack { + ForEach(0.. + + + + com.apple.developer.applesignin + + Default + + + diff --git a/ios/WildGrowth/WildGrowth/WildGrowthApp.swift b/ios/WildGrowth/WildGrowth/WildGrowthApp.swift new file mode 100644 index 0000000..c719cbc --- /dev/null +++ b/ios/WildGrowth/WildGrowth/WildGrowthApp.swift @@ -0,0 +1,45 @@ +import SwiftUI +import Kingfisher + +@main +struct WildGrowthApp: App { + // 接入 UserManager + @StateObject private var userManager = UserManager.shared + + init() { + // 🔥 优化 Kingfisher 配置,提升图片加载速度 + configureKingfisher() + // ✅ V1.0 埋点:初始化 + 记录冷启动 + AnalyticsManager.shared.track("app_launch") + } + + var body: some Scene { + WindowGroup { + // ✨ 统一入口:改为 SplashView + SplashView(isLoggedIn: $userManager.isLoggedIn) + .environmentObject(userManager) + } + } + + // MARK: - Kingfisher 优化配置 + private func configureKingfisher() { + // 1. 配置缓存:增大内存缓存,提升加载速度 + let cache = ImageCache.default + cache.memoryStorage.config.totalCostLimit = 150 * 1024 * 1024 // 150MB 内存缓存(增大) + cache.memoryStorage.config.countLimit = 150 // 最多缓存 150 张图片 + cache.memoryStorage.config.expiration = .seconds(3600) // 内存缓存 1 小时 + cache.diskStorage.config.sizeLimit = 1000 * 1024 * 1024 // 1GB 磁盘缓存(增大) + cache.diskStorage.config.expiration = .days(7) // 磁盘缓存 7 天 + + // 2. 配置下载器:优化下载性能 + let downloader = ImageDownloader.default + downloader.downloadTimeout = 20.0 // 20秒超时(增大) + // 允许并发下载更多图片 + downloader.sessionConfiguration.httpMaximumConnectionsPerHost = 6 // 每个主机最多 6 个并发连接 + + // 3. 启用自动缓存清理(避免缓存过大) + cache.cleanExpiredCache() + + print("✅ Kingfisher 配置完成:内存缓存 150MB,磁盘缓存 1GB,并发连接 6") + } +} diff --git a/ios/WildGrowth/WildGrowthTests/WildGrowthTests.swift b/ios/WildGrowth/WildGrowthTests/WildGrowthTests.swift new file mode 100644 index 0000000..e692b67 --- /dev/null +++ b/ios/WildGrowth/WildGrowthTests/WildGrowthTests.swift @@ -0,0 +1,16 @@ +// +// WildGrowthTests.swift +// WildGrowthTests +// +// Created by 杨一宸 on 2025/12/5. +// + +import Testing + +struct WildGrowthTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/ios/WildGrowth/WildGrowthUITests/WildGrowthUITests.swift b/ios/WildGrowth/WildGrowthUITests/WildGrowthUITests.swift new file mode 100644 index 0000000..3b30809 --- /dev/null +++ b/ios/WildGrowth/WildGrowthUITests/WildGrowthUITests.swift @@ -0,0 +1,43 @@ +// +// WildGrowthUITests.swift +// WildGrowthUITests +// +// Created by 杨一宸 on 2025/12/5. +// + +import XCTest + +final class WildGrowthUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/ios/WildGrowth/WildGrowthUITests/WildGrowthUITestsLaunchTests.swift b/ios/WildGrowth/WildGrowthUITests/WildGrowthUITestsLaunchTests.swift new file mode 100644 index 0000000..e9c1b35 --- /dev/null +++ b/ios/WildGrowth/WildGrowthUITests/WildGrowthUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// WildGrowthUITestsLaunchTests.swift +// WildGrowthUITests +// +// Created by 杨一宸 on 2025/12/5. +// + +import XCTest + +final class WildGrowthUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/ios/WildGrowth/字号优化说明.md b/ios/WildGrowth/字号优化说明.md new file mode 100644 index 0000000..75afd6d --- /dev/null +++ b/ios/WildGrowth/字号优化说明.md @@ -0,0 +1,94 @@ +# 字号优化说明 - 2026年2月 + +## 📊 优化原则 + +**核心理念**:调整略小的字号至合理层级,保持已经合理的字号不变,确保 UI 一致性和良好的阅读体验。 + +--- + +## ✅ 已调整的字号(略小 → 合理) + +### 1. 播放器正文区域 (`ContentBlockBuilder.swift`) + +| 元素 | 原字号 | 新字号 | 调整理由 | +|------|--------|--------|---------| +| 正文 | 17pt | **19pt** | 提升长文阅读舒适度,符合阅读类应用标准 | +| 主标题 | 28pt | **30pt** | 与正文字号成比例协调 | +| H1 标题 | 24pt | **26pt** | 与正文字号成比例协调 | +| H2 标题 | 20pt | **22pt** | 与正文字号成比例协调 | +| 高亮文本 | 18pt | **20pt** | 与正文字号成比例协调 | +| 行间距 | 9pt | **10pt** | 保持黄金比例 1.72x (19×1.72≈32.7) | +| 段落间距 | 22pt | **24pt** | 随正文字号增大 | + +**影响范围**: +- ✅ `VerticalScreenPlayerView` - 播放器正文渲染 +- ✅ `ArticleRichTextViewRepresentable` - 富文本显示 + +--- + +### 2. 地图页 (`MapView.swift`) + +| 元素 | 原字号 | 新字号 | 调整理由 | +|------|--------|--------|---------| +| 章节标题 | 20pt | **22pt** | 增强区块分割感,提升层级清晰度 | +| 课程标题 | 16pt | **17pt** | 略微偏小,提升至标准阅读字号 | +| 进度信息 | caption (11-12pt) | **label (13pt)** | caption 过小,调整至 label 防止难以阅读 | + +**影响范围**: +- ✅ `VerticalListLayout` - 章节标题 +- ✅ `LessonListRow` - 课程条目标题 +- ✅ `AtmosphericHeaderView` - 悬浮卡片进度信息 + +--- + +## 🔒 保持不变的合理字号 + +以下字号已处于最佳层级,**无需调整**: + +| 区域 | 元素 | 字号 | 评价 | +|------|------|------|------| +| **MapView** | 头部大标题 | 32pt | ✅ 适当,具有冲击力 | +| **ProfileView** | 统计数字 | 32pt | ✅ 适当,数据展示清晰 | +| **DiscoveryView** | 运营位标题 | 22pt | ✅ 适当,信息层级合理 | +| **GrowthTopBar** | 导航标签 | 18pt (active) / 16pt (inactive) | ✅ 适当,选中态明显 | +| **DesignSystem** | 各类按钮与标签 | 13pt-16pt | ✅ 适当,符合 iOS 设计规范 | +| **ProfileView** | 笔记入口卡片 | 16pt (title) / 13pt (label) | ✅ 适当,层级清晰 | +| **MapView** | 悬浮卡片按钮 | 15pt | ✅ 适当,易于点击 | + +--- + +## 📈 字号层级体系(完整) + +| 层级 | 字号范围 | 用途 | 示例 | +|------|----------|------|------| +| **Display** | 30-32pt | 页面主标题、数据展示 | 播放器主标题 (30pt)、统计数字 (32pt) | +| **H1** | 26-28pt | 一级标题 | 播放器 H1 (26pt) | +| **H2** | 22-24pt | 二级标题、模块标题 | 地图章节标题 (22pt)、发现页标题 (22pt) | +| **H3** | 18-20pt | 三级标题、Tab 标签 | 播放器高亮 (20pt)、Tab Bar 选中 (18pt) | +| **Body** | 17-19pt | 正文、列表项 | 播放器正文 (19pt)、地图课程标题 (17pt) | +| **Label** | 13-16pt | 辅助信息、按钮 | 进度信息 (13pt)、按钮文字 (16pt) | +| **Caption** | 11-12pt | 极小辅助文本(谨慎使用) | ⚠️ 已基本避免使用 | + +--- + +## 🎯 设计原则总结 + +1. **阅读优先**:正文区域字号要足够大(19pt),确保长时间阅读不疲劳 +2. **比例协调**:标题与正文成比例缩放,保持视觉节奏 +3. **层级清晰**:字号差异明显(至少 2-4pt),确保信息层级清晰 +4. **避免过小**:尽量避免使用 caption(11-12pt),最小使用 label(13pt) +5. **符合规范**:参考 Apple HIG 和主流阅读应用(微信读书、即刻等) + +--- + +## 📝 后续建议 + +1. **播放器体验**:可考虑根据用户反馈,提供字号调节功能(小、中、大) +2. **无障碍支持**:确保所有文字支持动态字体(Dynamic Type) +3. **对比测试**:可通过 A/B 测试验证字号调整对阅读完成率的影响 +4. **一致性检查**:定期审查新增页面,确保字号符合设计系统 + +--- + +**优化完成日期**:2026年2月1日 +**Git 提交记录**:`2a44791` - 优化字号层级,提升阅读体验 diff --git a/ios/WildGrowth/电子成长.xcodeproj/project.pbxproj b/ios/WildGrowth/电子成长.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fbcd13b --- /dev/null +++ b/ios/WildGrowth/电子成长.xcodeproj/project.pbxproj @@ -0,0 +1,676 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A328ABC72F01FCC20031E45F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = A328ABC62F01FCC20031E45F /* Kingfisher */; }; + A38376772F1489CF0027969A /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A38376762F1489CF0027969A /* MarkdownUI */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A357EF7E2EE29B140004B865 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A357EF642EE29B130004B865 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A357EF6B2EE29B130004B865; + remoteInfo = WildGrowth; + }; + A357EF882EE29B140004B865 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A357EF642EE29B130004B865 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A357EF6B2EE29B130004B865; + remoteInfo = WildGrowth; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + A357EF6C2EE29B130004B865 /* WildGrowth.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WildGrowth.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A357EF7D2EE29B140004B865 /* WildGrowthTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WildGrowthTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A357EF872EE29B140004B865 /* WildGrowthUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WildGrowthUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A357EF6E2EE29B130004B865 /* WildGrowth */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = WildGrowth; + sourceTree = ""; + }; + A357EF802EE29B140004B865 /* WildGrowthTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = WildGrowthTests; + sourceTree = ""; + }; + A357EF8A2EE29B140004B865 /* WildGrowthUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = WildGrowthUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A357EF692EE29B130004B865 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A328ABC72F01FCC20031E45F /* Kingfisher in Frameworks */, + A38376772F1489CF0027969A /* MarkdownUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A357EF7A2EE29B140004B865 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A357EF842EE29B140004B865 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A328ABC52F01FCC20031E45F /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + A357EF632EE29B130004B865 = { + isa = PBXGroup; + children = ( + A357EF6E2EE29B130004B865 /* WildGrowth */, + A357EF802EE29B140004B865 /* WildGrowthTests */, + A357EF8A2EE29B140004B865 /* WildGrowthUITests */, + A328ABC52F01FCC20031E45F /* Frameworks */, + A357EF6D2EE29B130004B865 /* Products */, + ); + sourceTree = ""; + }; + A357EF6D2EE29B130004B865 /* Products */ = { + isa = PBXGroup; + children = ( + A357EF6C2EE29B130004B865 /* WildGrowth.app */, + A357EF7D2EE29B140004B865 /* WildGrowthTests.xctest */, + A357EF872EE29B140004B865 /* WildGrowthUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A357EF6B2EE29B130004B865 /* WildGrowth */ = { + isa = PBXNativeTarget; + buildConfigurationList = A357EF912EE29B140004B865 /* Build configuration list for PBXNativeTarget "WildGrowth" */; + buildPhases = ( + A357EF682EE29B130004B865 /* Sources */, + A357EF692EE29B130004B865 /* Frameworks */, + A357EF6A2EE29B130004B865 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A328ABC42F01FA6A0031E45F /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A357EF6E2EE29B130004B865 /* WildGrowth */, + ); + name = WildGrowth; + packageProductDependencies = ( + A328ABC62F01FCC20031E45F /* Kingfisher */, + A38376762F1489CF0027969A /* MarkdownUI */, + ); + productName = WildGrowth; + productReference = A357EF6C2EE29B130004B865 /* WildGrowth.app */; + productType = "com.apple.product-type.application"; + }; + A357EF7C2EE29B140004B865 /* WildGrowthTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A357EF942EE29B140004B865 /* Build configuration list for PBXNativeTarget "WildGrowthTests" */; + buildPhases = ( + A357EF792EE29B140004B865 /* Sources */, + A357EF7A2EE29B140004B865 /* Frameworks */, + A357EF7B2EE29B140004B865 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A357EF7F2EE29B140004B865 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A357EF802EE29B140004B865 /* WildGrowthTests */, + ); + name = WildGrowthTests; + packageProductDependencies = ( + ); + productName = WildGrowthTests; + productReference = A357EF7D2EE29B140004B865 /* WildGrowthTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A357EF862EE29B140004B865 /* WildGrowthUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A357EF972EE29B140004B865 /* Build configuration list for PBXNativeTarget "WildGrowthUITests" */; + buildPhases = ( + A357EF832EE29B140004B865 /* Sources */, + A357EF842EE29B140004B865 /* Frameworks */, + A357EF852EE29B140004B865 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A357EF892EE29B140004B865 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A357EF8A2EE29B140004B865 /* WildGrowthUITests */, + ); + name = WildGrowthUITests; + packageProductDependencies = ( + ); + productName = WildGrowthUITests; + productReference = A357EF872EE29B140004B865 /* WildGrowthUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A357EF642EE29B130004B865 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + A357EF6B2EE29B130004B865 = { + CreatedOnToolsVersion = 16.2; + }; + A357EF7C2EE29B140004B865 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = A357EF6B2EE29B130004B865; + }; + A357EF862EE29B140004B865 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = A357EF6B2EE29B130004B865; + }; + }; + }; + buildConfigurationList = A357EF672EE29B130004B865 /* Build configuration list for PBXProject "电子成长" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A357EF632EE29B130004B865; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A328ABC22F01FA250031E45F /* XCLocalSwiftPackageReference "../../../../Downloads/Kingfisher-master" */, + A38376752F1489CF0027969A /* XCRemoteSwiftPackageReference "MarkdownUI" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A357EF6D2EE29B130004B865 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A357EF6B2EE29B130004B865 /* WildGrowth */, + A357EF7C2EE29B140004B865 /* WildGrowthTests */, + A357EF862EE29B140004B865 /* WildGrowthUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A357EF6A2EE29B130004B865 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A357EF7B2EE29B140004B865 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A357EF852EE29B140004B865 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A357EF682EE29B130004B865 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A357EF792EE29B140004B865 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A357EF832EE29B140004B865 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A328ABC42F01FA6A0031E45F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A328ABC32F01FA6A0031E45F /* Kingfisher */; + }; + A357EF7F2EE29B140004B865 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A357EF6B2EE29B130004B865 /* WildGrowth */; + targetProxy = A357EF7E2EE29B140004B865 /* PBXContainerItemProxy */; + }; + A357EF892EE29B140004B865 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A357EF6B2EE29B130004B865 /* WildGrowth */; + targetProxy = A357EF882EE29B140004B865 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + A357EF8F2EE29B140004B865 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A357EF902EE29B140004B865 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + A357EF922EE29B140004B865 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; + CODE_SIGN_ENTITLEMENTS = WildGrowth/WildGrowth.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"WildGrowth/Preview Content\""; + DEVELOPMENT_TEAM = TGTAAHD84B; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "电子成长"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 1.1.0; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.mustmaster.WildGrowth; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + XROS_DEPLOYMENT_TARGET = 2.2; + }; + name = Debug; + }; + A357EF932EE29B140004B865 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; + CODE_SIGN_ENTITLEMENTS = WildGrowth/WildGrowth.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"WildGrowth/Preview Content\""; + DEVELOPMENT_TEAM = TGTAAHD84B; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "电子成长"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 1.1.0; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.mustmaster.WildGrowth; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + XROS_DEPLOYMENT_TARGET = 2.2; + }; + name = Release; + }; + A357EF952EE29B140004B865 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TGTAAHD84B; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 1.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mustmaster.WildGrowthTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WildGrowth.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/WildGrowth"; + XROS_DEPLOYMENT_TARGET = 2.2; + }; + name = Debug; + }; + A357EF962EE29B140004B865 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TGTAAHD84B; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 1.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mustmaster.WildGrowthTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WildGrowth.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/WildGrowth"; + XROS_DEPLOYMENT_TARGET = 2.2; + }; + name = Release; + }; + A357EF982EE29B140004B865 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TGTAAHD84B; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 1.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mustmaster.WildGrowthUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = WildGrowth; + XROS_DEPLOYMENT_TARGET = 2.2; + }; + name = Debug; + }; + A357EF992EE29B140004B865 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TGTAAHD84B; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.5; + MARKETING_VERSION = 1.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mustmaster.WildGrowthUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = WildGrowth; + XROS_DEPLOYMENT_TARGET = 2.2; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A357EF672EE29B130004B865 /* Build configuration list for PBXProject "电子成长" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A357EF8F2EE29B140004B865 /* Debug */, + A357EF902EE29B140004B865 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A357EF912EE29B140004B865 /* Build configuration list for PBXNativeTarget "WildGrowth" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A357EF922EE29B140004B865 /* Debug */, + A357EF932EE29B140004B865 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A357EF942EE29B140004B865 /* Build configuration list for PBXNativeTarget "WildGrowthTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A357EF952EE29B140004B865 /* Debug */, + A357EF962EE29B140004B865 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A357EF972EE29B140004B865 /* Build configuration list for PBXNativeTarget "WildGrowthUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A357EF982EE29B140004B865 /* Debug */, + A357EF992EE29B140004B865 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A328ABC22F01FA250031E45F /* XCLocalSwiftPackageReference "../../../../Downloads/Kingfisher-master" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../../Downloads/Kingfisher-master"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + A38376752F1489CF0027969A /* XCRemoteSwiftPackageReference "MarkdownUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/MarkdownUI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A328ABC32F01FA6A0031E45F /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = A328ABC22F01FA250031E45F /* XCLocalSwiftPackageReference "../../../../Downloads/Kingfisher-master" */; + productName = Kingfisher; + }; + A328ABC62F01FCC20031E45F /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = A328ABC22F01FA250031E45F /* XCLocalSwiftPackageReference "../../../../Downloads/Kingfisher-master" */; + productName = Kingfisher; + }; + A38376762F1489CF0027969A /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = A38376752F1489CF0027969A /* XCRemoteSwiftPackageReference "MarkdownUI" */; + productName = MarkdownUI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A357EF642EE29B130004B865 /* Project object */; +} diff --git a/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..e573ac1 --- /dev/null +++ b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "f430e49d6b841dc65fa45490167bf3212b889ddf3e49a795fe473a7f0c2af4ac", + "pins" : [ + { + "identity" : "markdownui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/MarkdownUI", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + } + ], + "version" : 3 +} diff --git a/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcuserdata/yangyichen.xcuserdatad/WorkspaceSettings.xcsettings b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcuserdata/yangyichen.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/ios/WildGrowth/电子成长.xcodeproj/project.xcworkspace/xcuserdata/yangyichen.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/ios/WildGrowth/电子成长.xcodeproj/xcshareddata/xcschemes/WildGrowth.xcscheme b/ios/WildGrowth/电子成长.xcodeproj/xcshareddata/xcschemes/WildGrowth.xcscheme new file mode 100644 index 0000000..56997d8 --- /dev/null +++ b/ios/WildGrowth/电子成长.xcodeproj/xcshareddata/xcschemes/WildGrowth.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/WildGrowth/电子成长.xcodeproj/xcuserdata/yangyichen.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/WildGrowth/电子成长.xcodeproj/xcuserdata/yangyichen.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..de86231 --- /dev/null +++ b/ios/WildGrowth/电子成长.xcodeproj/xcuserdata/yangyichen.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,32 @@ + + + + + SchemeUserState + + WildGrowth.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + A357EF6B2EE29B130004B865 + + primary + + + A357EF7C2EE29B140004B865 + + primary + + + A357EF862EE29B140004B865 + + primary + + + + + diff --git a/ios/test-regression.sh b/ios/test-regression.sh new file mode 100755 index 0000000..4b8d1b8 --- /dev/null +++ b/ios/test-regression.sh @@ -0,0 +1,276 @@ +#!/bin/bash + +# 回归测试脚本 +# 用途:每次大规模代码移除后,自动运行回归测试,确保流程通畅 +# 使用方法:./test-regression.sh + +set -e # 遇到错误立即退出 + +echo "🧪 ========================================" +echo "🧪 开始回归测试" +echo "🧪 ========================================" +echo "" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 测试结果统计 +PASSED=0 +FAILED=0 +SKIPPED=0 + +# 测试函数 +test_check() { + local test_name="$1" + local command="$2" + + echo -n " ⏳ $test_name... " + + if eval "$command" > /dev/null 2>&1; then + echo -e "${GREEN}✅ 通过${NC}" + ((PASSED++)) + return 0 + else + echo -e "${RED}❌ 失败${NC}" + ((FAILED++)) + return 1 + fi +} + +test_check_with_output() { + local test_name="$1" + local command="$2" + + echo " ⏳ $test_name..." + echo " 执行: $command" + + if eval "$command" 2>&1; then + echo -e " ${GREEN}✅ 通过${NC}" + ((PASSED++)) + return 0 + else + echo -e " ${RED}❌ 失败${NC}" + ((FAILED++)) + return 1 + fi +} + +# ======================================== +# 1. 编译检查 +# ======================================== +echo "📦 阶段 1: 编译检查" +echo "----------------------------------------" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# 检查是否在 iOS 项目目录 +if [ ! -d "$PROJECT_ROOT/ios/WildGrowth" ]; then + echo -e "${RED}❌ 错误: 未找到 iOS 项目目录${NC}" + exit 1 +fi + +cd "$PROJECT_ROOT/ios/WildGrowth" + +# 检查 Xcode 项目文件 +if [ ! -f "WildGrowth.xcodeproj/project.pbxproj" ]; then + echo -e "${YELLOW}⚠️ 警告: 未找到 Xcode 项目文件,跳过编译检查${NC}" + SKIPPED=$((SKIPPED + 1)) +else + # 尝试编译(如果 xcodebuild 可用) + if command -v xcodebuild &> /dev/null; then + echo " ⏳ 编译项目..." + if xcodebuild -project WildGrowth.xcodeproj -scheme WildGrowth -destination 'platform=iOS Simulator,name=iPhone 15' clean build > /tmp/xcode_build.log 2>&1; then + echo -e " ${GREEN}✅ 编译成功${NC}" + PASSED=$((PASSED + 1)) + else + echo -e " ${RED}❌ 编译失败${NC}" + echo " 查看日志: tail -50 /tmp/xcode_build.log" + FAILED=$((FAILED + 1)) + fi + else + echo -e " ${YELLOW}⚠️ xcodebuild 不可用,跳过编译检查${NC}" + SKIPPED=$((SKIPPED + 1)) + fi +fi + +echo "" + +# ======================================== +# 2. 文件存在性检查 +# ======================================== +echo "📁 阶段 2: 文件存在性检查" +echo "----------------------------------------" + +cd "$PROJECT_ROOT/ios/WildGrowth/WildGrowth" + +# 检查应该存在的文件 +REQUIRED_FILES=( + "VerticalScreenPlayerView.swift" + "MapView.swift" + "HomeView.swift" + "Views/GrowthView.swift" + "ProfileView.swift" + "CourseModels.swift" +) + +for file in "${REQUIRED_FILES[@]}"; do + if [ -f "$file" ]; then + echo -e " ${GREEN}✅ $file 存在${NC}" + PASSED=$((PASSED + 1)) + else + echo -e " ${RED}❌ $file 不存在${NC}" + FAILED=$((FAILED + 1)) + fi +done + +echo "" + +# 检查应该删除的文件(不应该存在) +DELETED_FILES=( + "SummaryView.swift" + "GuestSummaryView.swift" + "FlatNodeButton.swift" + "ThreeDButton.swift" + "LessonPlayerView.swift" + "SlideImageView.swift" +) + +for file in "${DELETED_FILES[@]}"; do + if [ -f "$file" ]; then + echo -e " ${YELLOW}⚠️ $file 仍然存在(应该已删除)${NC}" + SKIPPED=$((SKIPPED + 1)) + else + echo -e " ${GREEN}✅ $file 已删除${NC}" + PASSED=$((PASSED + 1)) + fi +done + +echo "" + +# ======================================== +# 3. 代码引用检查 +# ======================================== +echo "🔍 阶段 3: 代码引用检查" +echo "----------------------------------------" + +cd "$PROJECT_ROOT/ios/WildGrowth/WildGrowth" + +# 检查是否还有对已删除组件的引用 +check_no_reference() { + local component="$1" + local pattern="$2" + + echo -n " ⏳ 检查 $component 引用... " + + if grep -r "$pattern" . --include="*.swift" > /dev/null 2>&1; then + echo -e "${RED}❌ 发现引用${NC}" + echo " 引用位置:" + grep -rn "$pattern" . --include="*.swift" | head -5 | sed 's/^/ /' + FAILED=$((FAILED + 1)) + return 1 + else + echo -e "${GREEN}✅ 无引用${NC}" + PASSED=$((PASSED + 1)) + return 0 + fi +} + +# 检查已删除组件的引用 +check_no_reference "SummaryView" "SummaryView" +check_no_reference "GuestSummaryView" "GuestSummaryView" +check_no_reference "FlatNodeButton" "FlatNodeButton" +check_no_reference "ThreeDButton" "ThreeDButton" +check_no_reference "LessonPlayerView" "LessonPlayerView\(" +check_no_reference "SlideImageView" "SlideImageView" + +echo "" + +# ======================================== +# 4. 关键逻辑检查 +# ======================================== +echo "🔧 阶段 4: 关键逻辑检查" +echo "----------------------------------------" + +cd "$PROJECT_ROOT/ios/WildGrowth/WildGrowth" + +# 检查 MapView 是否只使用 VerticalListLayout +echo -n " ⏳ 检查 MapView 布局... " +if grep -q "WindingPathLayout\|WindingNodeUnit" MapView.swift 2>/dev/null; then + echo -e "${RED}❌ 发现横版布局代码${NC}" + FAILED=$((FAILED + 1)) +else + if grep -q "VerticalListLayout" MapView.swift 2>/dev/null; then + echo -e "${GREEN}✅ 只使用竖屏布局${NC}" + PASSED=$((PASSED + 1)) + else + echo -e "${YELLOW}⚠️ 未找到 VerticalListLayout${NC}" + SKIPPED=$((SKIPPED + 1)) + fi +fi + +# 检查 HomeView 是否只使用 VerticalScreenPlayerView +echo -n " ⏳ 检查 HomeView 播放器... " +if grep -q "LessonPlayerView" HomeView.swift 2>/dev/null; then + echo -e "${RED}❌ 发现 LessonPlayerView 引用${NC}" + FAILED=$((FAILED + 1)) +else + if grep -q "VerticalScreenPlayerView" HomeView.swift 2>/dev/null; then + echo -e "${GREEN}✅ 只使用竖屏播放器${NC}" + PASSED=$((PASSED + 1)) + else + echo -e "${YELLOW}⚠️ 未找到 VerticalScreenPlayerView${NC}" + SKIPPED=$((SKIPPED + 1)) + fi +fi + +# 检查 CourseNavigation 是否还有 .player case +echo -n " ⏳ 检查 CourseNavigation 枚举... " +if grep -q "case.*player" HomeView.swift 2>/dev/null; then + echo -e "${YELLOW}⚠️ 发现 .player case(可能需要移除)${NC}" + SKIPPED=$((SKIPPED + 1)) +else + echo -e "${GREEN}✅ 无 .player case${NC}" + PASSED=$((PASSED + 1)) +fi + +echo "" + +# ======================================== +# 5. 代码统计 +# ======================================== +echo "📊 阶段 5: 代码统计" +echo "----------------------------------------" + +cd "$PROJECT_ROOT/ios/WildGrowth/WildGrowth" + +# 统计 Swift 文件数量 +SWIFT_FILES=$(find . -name "*.swift" -type f | wc -l | tr -d ' ') +echo " 📄 Swift 文件数量: $SWIFT_FILES" + +# 统计代码行数(排除空行和注释) +CODE_LINES=$(find . -name "*.swift" -type f -exec cat {} \; | grep -v '^\s*$' | grep -v '^\s*//' | wc -l | tr -d ' ') +echo " 📝 代码行数(估算): $CODE_LINES" + +echo "" + +# ======================================== +# 6. 测试总结 +# ======================================== +echo "📋 测试总结" +echo "========================================" +echo -e " ${GREEN}✅ 通过: $PASSED${NC}" +echo -e " ${RED}❌ 失败: $FAILED${NC}" +echo -e " ${YELLOW}⚠️ 跳过: $SKIPPED${NC}" +echo "" + +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}🎉 所有测试通过!${NC}" + exit 0 +else + echo -e "${RED}❌ 有测试失败,请检查${NC}" + exit 1 +fi