Sfoglia il codice sorgente

feat: 第二阶段开发完成

功能实现:
- 专辑功能 (Album) - 数据库模型、CRUD API、订阅功能
- 首页改版 - 展示专辑列表,专辑为基本单位
- 播放器优化 - 15项功能全部实现
  * 封面旋转动画、毛玻璃背景
  * 播放控制、进度条、播放列表
  * 循环模式、随机播放、播放速度
  * 收藏、定时关闭、按钮动画
  * 专辑信息展示、智能续播、自动播放下一首
- 分类管理、搜索功能、全局搜索API
- 消息通知、评论等辅助功能

UI改进:
- 播放器控制按钮图标颜色修复
- 播放列表默认展开

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MyFramework User 5 mesi fa
parent
commit
c78a5138c6

+ 140 - 22
.codebuddy/rules/harness.mdc

@@ -2,7 +2,7 @@
 description: 
 description: 
 alwaysApply: true
 alwaysApply: true
 enabled: true
 enabled: true
-updatedAt: 2026-04-04T02:08:26.852Z
+updatedAt: 2026-04-05T01:04:17.516Z
 provider: 
 provider: 
 ---
 ---
 
 
@@ -10,33 +10,37 @@ provider:
 
 
 ## 核心原则
 ## 核心原则
 
 
-1. **双 Agent 架构**:Initializer Agent(仅首次)+ Coding Agent(后续所有会话)
+1. **三 Agent 架构**:
+   - **Initializer Agent**:仅首次运行,初始化项目
+   - **Coding Agent**:开发功能
+   - **Reviewer Agent**:质量检查(对照功能表 + 浏览器验证)
 2. **外部持久化**:所有进度必须写入文件,不依赖 AI 记忆
 2. **外部持久化**:所有进度必须写入文件,不依赖 AI 记忆
 3. **增量开发**:每次会话只完成 1 个功能
 3. **增量开发**:每次会话只完成 1 个功能
 4. **状态流转**:功能状态按流程逐步推进
 4. **状态流转**:功能状态按流程逐步推进
 5. **自动执行**:AI 必须自动执行所有命令,禁止让用户手动执行
 5. **自动执行**:AI 必须自动执行所有命令,禁止让用户手动执行
 6. **失败处理**:验证失败 → 修复 → 重新执行该环节
 6. **失败处理**:验证失败 → 修复 → 重新执行该环节
+7. **完整实现**:**功能完成 = 后端 API + 前端页面**,缺一不可
+8. **质量审查**:Coding Agent 完成后 → Reviewer Agent 检查
 
 
-### 状态流转
+### 状态流转(必须完整执行)
 
 
 ```
 ```
 init → start → compiling → running → db-checking → backend-testing → frontend-testing → done
 init → start → compiling → running → db-checking → backend-testing → frontend-testing → done
-                                                    ↑                      ↑
-                                              【后端测试】          【前端测试⚠️】
+                                                                                    ↑
+                                                                            【必须测试前端⚠️】
+                                                                            禁止跳过!
 ```
 ```
 
 
-| 状态 | 含义 |
-|------|------|
-| `init` | 功能已创建,未开始 |
-| `start` | 开始开发 |
-| `compiling` | 编译中 |
-| `running` | 运行中 |
-| `db-checking` | 数据库验证中 |
-| `backend-testing` | 后端接口测试中(用 curl) |
-| `frontend-testing` | 前端页面测试中(用 Playwright)⚠️ |
-| `done` | 功能完成 |
+| 状态 | 含义 | 工具 |
+|------|------|------|
+| `backend-testing` | 后端接口测试 | curl |
+| `frontend-testing` | 前端页面测试 | Playwright ⚠️ |
 
 
-**⚠️ 注意**:必须测试前端页面,不能只用 curl 测试后端就结束
+**⚠️⚠️⚠️ 强制要求 ⚠️⚠️⚠️**
+- **必须测试前端页面**
+- **禁止跳过 frontend-testing 步骤**
+- **禁止用 curl 代替 Playwright 测试前端**
+- **功能完成 = 后端测试通过 + 前端测试通过**
 
 
 ---
 ---
 
 
@@ -126,6 +130,14 @@ python backend/db_check.py
 - 使用 curl 测试 API 接口
 - 使用 curl 测试 API 接口
 - 开发阶段 `auth_enabled: false`,跳过 token 验证
 - 开发阶段 `auth_enabled: false`,跳过 token 验证
 
 
+---
+
+### ⚠️⚠️⚠️ 重要:必须继续执行步骤 9 ⚠️⚠️⚠️
+
+**后端测试通过后,必须继续执行步骤 9(前端测试)**
+**禁止在步骤 8 完成后直接标记 done**
+**功能完成 = 后端测试通过 + 前端测试通过**
+
 #### ⚠️ 步骤 9:前端页面测试(状态 → frontend-testing)⚠️
 #### ⚠️ 步骤 9:前端页面测试(状态 → frontend-testing)⚠️
 **【必须测试前端,禁止跳过此步骤】**
 **【必须测试前端,禁止跳过此步骤】**
 ```bash
 ```bash
@@ -146,19 +158,35 @@ npx playwright test
 # Git 提交
 # Git 提交
 ```
 ```
 
 
-### 禁止行为
+**⚠️⚠️⚠️ 重要:功能完成必须满足以下条件 ⚠️⚠️⚠️**
+- ✅ 后端 API 已实现(通过 backend_test_steps 验证)
+- ✅ 前端页面已实现(通过 frontend_test_steps 验证)
+- ✅ 功能完整可用(不是半成品)
+- ❌ 不能只实现后端就标记 done
+- ❌ 不能只标记状态不写代码
+
+### ⚠️ 禁止行为(违反则任务失败)⚠️
+
+- ❌ **只实现后端 API,不实现前端页面**⚠️⚠️⚠️
+- ❌ **只标记状态为 done,不实际编写代码**⚠️⚠️⚠️
+- ❌ **跳过前端开发步骤**(只做 backend_test_steps,不做 frontend_test_steps)⚠️
+- ❌ **跳过前端测试步骤**(只测后端就标记 done)⚠️
+- ❌ **用 curl 测试前端页面**(curl 只能测后端)
 - ❌ 每次做多个功能
 - ❌ 每次做多个功能
 - ❌ 跳过状态直接 done
 - ❌ 跳过状态直接 done
 - ❌ 不编译就测试
 - ❌ 不编译就测试
 - ❌ 不运行就测试
 - ❌ 不运行就测试
 - ❌ 不验证数据库连接
 - ❌ 不验证数据库连接
-- ❌ **忘记测试前端**(只测后端,不测前端)
-- ❌ **用 curl 测试前端页面**(禁止 curl 测试前端)
 - ❌ 删除/修改 backend_test_steps
 - ❌ 删除/修改 backend_test_steps
 - ❌ 删除/修改 frontend_test_steps
 - ❌ 删除/修改 frontend_test_steps
 - ❌ backend_test_steps 不包含增删改查
 - ❌ backend_test_steps 不包含增删改查
 - ❌ 输出命令让用户手动执行
 - ❌ 输出命令让用户手动执行
 
 
+**⚠️⚠️⚠️ 核心违规 ⚠️⚠️⚠️**
+- **只实现 API 不算完成 = 必须同时有后端 + 前端代码**
+- **跳过前端开发/测试 = 任务失败**
+- **只标记状态不写代码 = 任务失败**
+
 ---
 ---
 
 
 ## 工件规范
 ## 工件规范
@@ -185,8 +213,11 @@ npx playwright test
         "4. curl DELETE /api/chats/{id} - 验证删除(删)"
         "4. curl DELETE /api/chats/{id} - 验证删除(删)"
       ],
       ],
       "frontend_test_steps": [
       "frontend_test_steps": [
-        "1. 点击按钮",
-        "2. 验证结果"
+        "1. 在前端页面创建分类输入框",
+        "2. 点击新增按钮",
+        "3. 验证分类显示在列表中",
+        "4. 点击分类筛选按钮",
+        "5. 验证列表按分类筛选"
       ],
       ],
       "status": "init",
       "status": "init",
       "passes": false
       "passes": false
@@ -195,6 +226,11 @@ npx playwright test
 }
 }
 ```
 ```
 
 
+**⚠️ 重要:功能完成条件 ⚠️**
+- 功能完成 = 后端 API 实现 + 前端页面实现
+- 缺一不可:只有后端不算完成,只有前端也不算完成
+- frontend_test_steps 必须包含具体的前端操作(如:点击按钮、输入文本、验证显示)
+
 ### agent-progress.txt
 ### agent-progress.txt
 - 格式:纯文本
 - 格式:纯文本
 - 要求:每次会话追加记录,保留所有历史
 - 要求:每次会话追加记录,保留所有历史
@@ -213,4 +249,86 @@ npx playwright test
 - `auth_enabled: false`:开发阶段,跳过登录验证 token
 - `auth_enabled: false`:开发阶段,跳过登录验证 token
 - `auth_enabled: true`:生产阶段,开启登录验证 token
 - `auth_enabled: true`:生产阶段,开启登录验证 token
 
 
-**建议**:开发阶段关闭 `auth_enabled`,等核心功能完成后再开启
+**建议**:开发阶段关闭 `auth_enabled`,等核心功能完成后再开启
+
+---
+
+## Reviewer Agent 规则
+
+### 职责
+在 Coding Agent 完成后进行质量检查:
+1. 对照 `feature_list.json` 验证功能是否实现
+2. 检查后端代码逻辑是否正确
+3. **用 Playwright 浏览器验证前端效果**
+4. 发现问题反馈给 Coding Agent 修复
+
+### 工作流程
+
+#### 步骤 1:读取功能清单
+```bash
+cat feature_list.json
+```
+
+#### 步骤 2:检查后端代码
+```bash
+# 检查 API 是否实现
+ls backend/
+cat backend/api/*.py
+```
+
+#### 步骤 3:检查前端代码
+```bash
+# 检查页面是否存在
+ls frontend/src/views/
+cat frontend/src/views/*.vue
+```
+
+#### 步骤 4:浏览器验证(必须执行)
+```bash
+# 用 Playwright 验证前端效果
+npx playwright test --reporter=line
+```
+
+验证:
+- ✅ 页面能正常打开
+- ✅ 按钮能点击
+- ✅ 功能能正常使用
+- ✅ 数据显示正确
+
+#### 步骤 5:生成审查报告
+```bash
+cat > feature-review-report.md << 'EOF'
+# 功能审查报告
+
+## 功能 X 审查结果
+
+| 检查项 | 状态 | 说明 |
+|--------|------|------|
+| 后端代码 | ✅/❌ | |
+| 前端代码 | ✅/❌ | |
+| 浏览器验证 | ✅/❌ | |
+
+### 问题列表
+1. [问题描述]
+
+### 结论
+- ✅ 通过 → 功能可以标记为完成
+- ❌ 不通过 → 返回 Coding Agent 修复
+EOF
+```
+
+### 审查标准
+
+| 问题类型 | 严重程度 | 处理方式 |
+|---------|---------|---------|
+| 功能完全没实现 | 🔴 严重 | 返回重做 |
+| 部分功能缺失 | 🟡 中等 | 返回补充 |
+| UI 样式问题 | 🟢 轻微 | 可忽略 |
+
+### 核心原则
+
+> **Reviewer Agent 是质量把关者**
+> 
+> **发现问题必须反馈,不能视而不见**
+> 
+> **浏览器验证是必须的**

+ 7 - 0
agent-progress.txt

@@ -76,3 +76,10 @@ curl "http://localhost:3000/api/albums/1/check-subscribe?userId=1" - 检查订
 - cover组件添加 :class="{ rotating: isPlaying }" 绑定
 - cover组件添加 :class="{ rotating: isPlaying }" 绑定
 - 播放时封面旋转,暂停时保持当前角度
 - 播放时封面旋转,暂停时保持当前角度
 - Playwright测试通过:封面元素存在,CSS动画定义正确
 - Playwright测试通过:封面元素存在,CSS动画定义正确
+
+=== 2026-04-05 功能修复 ===
+Feature 13修复: 专辑信息展示
+- 之前显示: "0:01 · 2字"
+- 现在显示: "默认专辑 · 1/7"
+- 播放列表加载修复: ID类型比较问题
+- 提交: 486db61

+ 145 - 0
docs/播放页设计方案.md

@@ -0,0 +1,145 @@
+# 播放页优化方案
+
+> 参考喜马拉雅等主流音频软件设计
+
+## 一、整体布局设计
+
+```
+┌─────────────────────────────────────┐
+│  ← 返回     专辑名称     ⋮ 更多    │  顶部导航
+├─────────────────────────────────────┤
+│                                     │
+│         ┌─────────────┐             │
+│         │   旋转唱片   │             │  封面区域
+│         │    封面     │             │  (带旋转动画)
+│         └─────────────┘             │
+│                                     │
+│         音频标题                     │  音频信息
+│         专辑名 · 集数 2/10          │
+│                                     │
+│  0:00 ════════════════○──────── 3:45 │  进度条
+│                                     │
+│   🔀      ⏮      ▶      ⏭      🔁  │  播放控制
+│  随机      上一    播放    下一    循环│
+│                                     │
+│  ⏰定时    ❤️收藏    ⬇下载    ↗分享  │  辅助功能
+│                                     │
+├─────────────────────────────────────┤
+│  📋 播放列表                         │  播放列表
+│  ┌───────────────────────────────┐  │  (可折叠)
+│  │ 1. 第一章 - 正在播放    ▶     │  │
+│  │ 2. 第二章 - 未播放           │  │
+│  │ 3. 第三章 - 未播放           │  │
+│  └───────────────────────────────┘  │
+└─────────────────────────────────────┘
+```
+
+## 二、核心功能模块
+
+### 1. 封面展示区
+- **旋转唱片效果**:播放时封面缓慢旋转,暂停时停止
+- **毛玻璃背景**:封面模糊处理作为页面背景
+- **光晕效果**:封面周围添加渐变光晕
+
+### 2. 音频信息展示
+- **标题**:大字显示音频名称
+- **专辑信息**:专辑名 + 当前集数/总集数
+- **作者信息**:主播/作者名称 + 订阅按钮
+
+### 3. 进度控制区
+- **进度条**:可拖动,显示播放进度
+- **时间显示**:当前时间 / 总时长
+- **波纹动画**:播放时进度条有动态波纹效果
+
+### 4. 播放控制区
+
+| 功能 | 图标 | 说明 |
+|------|------|------|
+| 随机播放 | 🔀 | 开启/关闭随机播放模式 |
+| 上一首 | ⏮ | 播放上一集 |
+| 播放/暂停 | ▶/⏸ | 主播放按钮(大尺寸) |
+| 下一首 | ⏭ | 播放下一集 |
+| 循环模式 | 🔁 | 列表循环/单曲循环/关闭 |
+
+### 5. 辅助功能区
+
+| 功能 | 说明 |
+|------|------|
+| 定时关闭 | 15/30/45/60分钟/播完关闭 |
+| 收藏 | 心形图标,收藏当前音频 |
+| 下载 | 下载当前音频到本地 |
+| 播放速度 | 0.5x/0.75x/1x/1.25x/1.5x/2x |
+| 音质 | 标准/高品质切换 |
+| 分享 | 分享给好友 |
+
+### 6. 播放列表(可折叠)
+- 显示当前专辑/列表所有音频
+- 当前播放高亮显示
+- 点击切换播放
+- 支持拖动排序(可选)
+
+## 三、视觉效果升级
+
+### 配色方案
+
+```css
+/* 深色主题 - 沉浸式体验 */
+background: linear-gradient(180deg, #1a1a2e 0%, #16213e 50%, #0f0f23 100%);
+
+/* 主题色 */
+primary: #4F46E5 (紫色)
+accent: #818CF8 (浅紫色)
+text-primary: #FFFFFF
+text-secondary: #9CA3AF
+```
+
+### 动画效果
+1. **唱片旋转**:播放时每20秒转一圈
+2. **按钮反馈**:点击缩放动画
+3. **进度条波纹**:播放时动态波纹
+4. **页面转场**:淡入淡出效果
+
+## 四、交互优化
+
+1. **智能续播**:记住上次播放位置,提示继续播放
+2. **自动播放下一首**:可选开启
+3. **后台播放**:切换页面继续播放
+4. **锁屏控制**:支持系统锁屏控件
+5. **蓝牙控制**:支持蓝牙耳机切歌
+
+## 五、需要新增/修改的文件
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `pages/player/index.vue` | 修改 | 重构播放页UI |
+| `store/audio.ts` | 修改 | 添加循环模式、随机播放等功能 |
+| `components/PlayerCover.vue` | 新增 | 可复用封面组件 |
+| `components/PlaylistPanel.vue` | 新增 | 播放列表面板组件 |
+
+## 六、实现优先级
+
+### P0 - 核心功能
+- [ ] 封面旋转动画
+- [ ] 播放控制(上一首/下一首/播放暂停)
+- [ ] 进度条拖动
+- [ ] 播放列表展示
+
+### P1 - 重要功能
+- [ ] 循环模式(列表循环/单曲循环)
+- [ ] 随机播放
+- [ ] 播放速度控制
+- [ ] 收藏功能
+- [ ] 定时关闭
+
+### P2 - 增强功能
+- [ ] 下载功能
+- [ ] 分享功能
+- [ ] 音质切换
+- [ ] 毛玻璃背景效果
+- [ ] 播放列表折叠/展开
+
+### P3 - 优化功能
+- [ ] 按钮点击动画
+- [ ] 页面转场动画
+- [ ] 锁屏控件支持
+- [ ] 后台播放优化

+ 177 - 115
feature_list.json

@@ -1,5 +1,5 @@
 {
 {
-  "project_name": "AI有声书生成工具 - 第二期",
+  "project_name": "AI有声书生成工具",
   "base_config": {
   "base_config": {
     "backend_port": 3000,
     "backend_port": 3000,
     "frontend_port": 5173,
     "frontend_port": 5173,
@@ -129,7 +129,7 @@
       "id": 8,
       "id": 8,
       "description": "分享功能 - 分享卡片生成",
       "description": "分享功能 - 分享卡片生成",
       "backend_test_steps": [
       "backend_test_steps": [
-        "1. curl http://localhost:3000/api/share?audioId=7 - 验证分享信息接口返回正确数据"
+        "1. curl http://localhost:3000/api/share?audioId=audio-001 - 验证分享信息接口返回正确数据"
       ],
       ],
       "frontend_test_steps": [
       "frontend_test_steps": [
         "1. 打开player页面",
         "1. 打开player页面",
@@ -169,119 +169,6 @@
       "status": "done",
       "status": "done",
       "passes": true
       "passes": true
     },
     },
-    {
-      "id": 11,
-      "description": "全局搜索 - 搜索API",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/search?q=你好 - 验证搜索接口"
-      ],
-      "frontend_test_steps": [
-        "1. 进入首页点击搜索图标",
-        "2. 输入搜索关键词",
-        "3. 验证搜索结果列表正常显示"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 12,
-      "description": "全局搜索 - search页面",
-      "backend_test_steps": [],
-      "frontend_test_steps": [
-        "1. 进入搜索页面",
-        "2. 验证搜索功能正常"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 13,
-      "description": "内容分类 - 分类管理API",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/categories - 验证获取分类列表"
-      ],
-      "frontend_test_steps": [
-        "1. 进入首页",
-        "2. 验证分类标签显示正确"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 14,
-      "description": "内容分类 - 分类筛选",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/categories/1 - 验证获取分类下音频"
-      ],
-      "frontend_test_steps": [
-        "1. 在首页切换不同分类标签",
-        "2. 验证列表内容随分类变化"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 15,
-      "description": "音质选择 - 音质切换逻辑",
-      "backend_test_steps": [],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 点击音质选择按钮",
-        "3. 切换到高品质",
-        "4. 验证音质切换生效"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 16,
-      "description": "离线缓存 - 下载管理",
-      "backend_test_steps": [],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 点击下载按钮",
-        "3. 验证下载功能工作"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 17,
-      "description": "离线缓存 - 离线播放",
-      "backend_test_steps": [],
-      "frontend_test_steps": [
-        "1. 下载音频后",
-        "2. 验证离线播放功能"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 18,
-      "description": "评论打分 - 评论API",
-      "backend_test_steps": [
-        "1. curl -X POST http://localhost:3000/api/comments - 验证发表评论"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 验证评论功能"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 19,
-      "description": "消息通知 - 通知API",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/notifications - 验证获取通知列表"
-      ],
-      "frontend_test_steps": [
-        "1. 验证通知图标显示",
-        "2. 验证通知列表正常显示"
-      ],
-      "status": "done",
-      "passes": true
-    }
     {
     {
       "id": 11,
       "id": 11,
       "description": "全局搜索 - 搜索API",
       "description": "全局搜索 - 搜索API",
@@ -431,6 +318,181 @@
       ],
       ],
       "status": "done",
       "status": "done",
       "passes": true
       "passes": true
+    },
+    {
+      "id": 20,
+      "description": "专辑功能 - 数据库表设计",
+      "backend_test_steps": [
+        "1. curl -X POST http://localhost:3000/api/albums -H 'Content-Type: application/json' -d '{\"name\":\"测试专辑\",\"description\":\"测试描述\",\"coverUrl\":\"https://xxx.jpg\"}' - 验证创建专辑",
+        "2. curl http://localhost:3000/api/albums - 验证获取专辑列表",
+        "3. curl http://localhost:3000/api/albums/1 - 验证获取专辑详情",
+        "4. curl -X PUT http://localhost:3000/api/albums/1 -H 'Content-Type: application/json' -d '{\"name\":\"更新后的专辑名\"}' - 验证更新专辑",
+        "5. curl -X DELETE http://localhost:3000/api/albums/1 - 验证删除专辑"
+      ],
+      "frontend_test_steps": [
+        "1. 进入专辑页面",
+        "2. 验证专辑列表正常显示",
+        "3. 点击某个专辑进入详情",
+        "4. 验证专辑封面、名称、描述显示正确"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 21,
+      "description": "专辑功能 - 专辑内音频管理API",
+      "backend_test_steps": [
+        "1. curl -X POST http://localhost:3000/api/albums/1/audio -H 'Content-Type: application/json' -d '{\"audioId\":\"audio-001\",\"order\":1}' - 验证添加音频到专辑",
+        "2. curl http://localhost:3000/api/albums/1/audios - 验证获取专辑内音频列表",
+        "3. curl -X PUT http://localhost:3000/api/albums/1/audio/audio-001 -H 'Content-Type: application/json' -d '{\"order\":2}' - 验证调整音频顺序",
+        "4. curl -X DELETE http://localhost:3000/api/albums/1/audio/audio-001 - 验证从专辑移除音频"
+      ],
+      "frontend_test_steps": [
+        "1. 进入专辑详情页",
+        "2. 验证专辑内音频列表按顺序显示",
+        "3. 验证音频数量正确"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 22,
+      "description": "专辑功能 - 专辑详情页开发",
+      "backend_test_steps": [
+        "1. curl http://localhost:3000/api/albums/1 - 验证专辑详情接口返回正确数据"
+      ],
+      "frontend_test_steps": [
+        "1. 进入专辑详情页",
+        "2. 验证专辑封面、名称、描述显示正确",
+        "3. 验证音频列表按顺序显示",
+        "4. 点击播放全部按钮",
+        "5. 验证进入播放器并开始连续播放"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 23,
+      "description": "专辑功能 - 专辑列表页开发",
+      "backend_test_steps": [
+        "1. curl http://localhost:3000/api/albums - 验证专辑列表接口",
+        "2. curl http://localhost:3000/api/albums?category=1 - 验证分类筛选专辑"
+      ],
+      "frontend_test_steps": [
+        "1. 进入专辑页面",
+        "2. 验证专辑列表网格/列表显示正确",
+        "3. 验证封面图、名称、音频数量显示",
+        "4. 下拉刷新列表",
+        "5. 验证列表正常加载"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 24,
+      "description": "专辑功能 - 订阅/关注专辑",
+      "backend_test_steps": [
+        "1. curl -X POST http://localhost:3000/api/albums/1/subscribe -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\"}' - 验证订阅专辑",
+        "2. curl http://localhost:3000/api/albums/subscribed?userId=test-user - 验证获取已订阅专辑列表",
+        "3. curl -X DELETE http://localhost:3000/api/albums/1/subscribe - 验证取消订阅"
+      ],
+      "frontend_test_steps": [
+        "1. 进入专辑详情页",
+        "2. 点击订阅/关注按钮",
+        "3. 验证按钮状态变为已订阅",
+        "4. 进入我的订阅页面",
+        "5. 验证已订阅专辑显示在列表中",
+        "6. 点击取消订阅",
+        "7. 验证专辑从列表移除"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 25,
+      "description": "专辑功能 - 专辑连续播放",
+      "backend_test_steps": [
+        "1. curl http://localhost:3000/api/albums/1/audios - 验证获取专辑音频列表接口"
+      ],
+      "frontend_test_steps": [
+        "1. 进入专辑详情页",
+        "2. 点击播放全部",
+        "3. 验证播放器显示专辑信息",
+        "4. 当前音频播放完毕后自动播放下一首",
+        "5. 验证播放列表显示专辑内所有音频",
+        "6. 可手动切换上一首/下一首"
+      ],
+      "status": "done",
+      "passes": true
+    },
+    {
+      "id": 26,
+      "description": "播放页优化 - 旋转唱片效果",
+      "backend_test_steps": [],
+      "frontend_test_steps": [
+        "1. 进入播放页",
+        "2. 验证封面显示在中央",
+        "3. 开始播放,验证封面旋转动画",
+        "4. 暂停播放,验证旋转停止",
+        "5. 继续播放,验证旋转恢复"
+      ],
+      "status": "init",
+      "passes": false
+    },
+    {
+      "id": 27,
+      "description": "播放页优化 - 播放列表展示",
+      "backend_test_steps": [],
+      "frontend_test_steps": [
+        "1. 进入播放页",
+        "2. 验证播放列表显示在底部",
+        "3. 点击播放列表展开按钮",
+        "4. 验证当前播放音频高亮显示",
+        "5. 点击列表中的音频切换播放"
+      ],
+      "status": "init",
+      "passes": false
+    },
+    {
+      "id": 28,
+      "description": "播放页优化 - 循环模式(列表循环/单曲循环)",
+      "backend_test_steps": [],
+      "frontend_test_steps": [
+        "1. 进入播放页",
+        "2. 验证循环模式按钮显示",
+        "3. 点击切换循环模式",
+        "4. 验证模式依次变化:列表循环 → 单曲循环 → 关闭",
+        "5. 播放完毕验证按当前模式处理"
+      ],
+      "status": "init",
+      "passes": false
+    },
+    {
+      "id": 29,
+      "description": "播放页优化 - 随机播放模式",
+      "backend_test_steps": [],
+      "frontend_test_steps": [
+        "1. 进入播放页",
+        "2. 验证随机播放按钮显示",
+        "3. 点击开启随机播放",
+        "4. 验证点击下一首时随机播放列表中任意一首",
+        "5. 关闭随机播放,验证按顺序播放"
+      ],
+      "status": "init",
+      "passes": false
+    },
+    {
+      "id": 30,
+      "description": "播放页优化 - 毛玻璃背景效果",
+      "backend_test_steps": [],
+      "frontend_test_steps": [
+        "1. 进入播放页",
+        "2. 验证页面背景使用封面模糊图",
+        "3. 验证背景有渐变叠加效果",
+        "4. 验证文字清晰可读"
+      ],
+      "status": "init",
+      "passes": false
     }
     }
   ]
   ]
 }
 }

+ 0 - 326
feature_list_phase2.json

@@ -1,326 +0,0 @@
-{
-  "project_name": "AI有声书生成工具 - 第二期",
-  "base_config": {
-    "backend_port": 3000,
-    "frontend_port": 5173,
-    "db_host": "localhost",
-    "db_port": 3306,
-    "auth_enabled": false
-  },
-  "features": [
-    {
-      "id": 1,
-      "description": "播放记录 - 数据库表设计",
-      "status": "done",
-      "passes": true,
-      "backend_test_steps": [
-        "1. curl -X POST http://localhost:3000/api/player/progress -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"audioId\":\"audio-001\",\"progress\":30.5,\"duration\":180}' - 验证创建播放记录",
-        "2. curl http://localhost:3000/api/player/progress?userId=test-user - 验证查询播放记录列表",
-        "3. curl -X PUT http://localhost:3000/api/player/progress/audio-001 -H 'Content-Type: application/json' -d '{\"progress\":60.0}' - 验证更新播放进度",
-        "4. curl -X DELETE http://localhost:3000/api/player/progress/audio-001 - 验证删除播放记录"
-      ],
-      "frontend_test_steps": [
-        "1. 打开播放器页面",
-        "2. 点击播放音频",
-        "3. 等待5秒后查看播放进度是否自动保存",
-        "4. 刷新页面验证续播位置是否正确"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 2,
-      "description": "播放记录 - 前端播放器集成",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/player/progress?userId=test-user - 验证获取播放进度接口正常"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 选择一个有播放进度的音频",
-        "3. 验证是否从上次进度位置开始播放",
-        "4. 验证播放进度每5秒自动保存"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 3,
-      "description": "收藏功能 - 数据库表设计",
-      "backend_test_steps": [
-        "1. curl -X POST http://localhost:3000/api/favorites -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"audioId\":\"audio-002\"}' - 验证添加收藏",
-        "2. curl http://localhost:3000/api/favorites?userId=test-user - 验证查询收藏列表",
-        "3. curl -X DELETE http://localhost:3000/api/favorites/audio-002 - 验证取消收藏",
-        "4. curl -X POST http://localhost:3000/api/favorites -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"audioId\":\"audio-003\"}' - 重新添加收藏用于后续测试"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 点击收藏按钮",
-        "3. 验证收藏状态变为已收藏",
-        "4. 进入favorites页面",
-        "5. 验证收藏的音频显示在列表中",
-        "6. 点击取消收藏按钮",
-        "7. 验证列表中该音频已移除"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 4,
-      "description": "收藏功能 - favorites页面开发",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/favorites?userId=test-user - 验证收藏列表查询"
-      ],
-      "frontend_test_steps": [
-        "1. 进入favorites收藏页面",
-        "2. 验证页面正常加载,显示收藏列表",
-        "3. 点击某个音频进入播放器",
-        "4. 返回favorites页面验证列表刷新正常"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 5,
-      "description": "定时关闭 - 前端定时器实现",
-      "backend_test_steps": [],
-      "frontend_test_steps": [
-        "1. 打开player页面并开始播放",
-        "2. 点击定时关闭按钮",
-        "3. 选择15分钟定时",
-        "4. 验证倒计时显示正确",
-        "5. 等待或快进到定时时间",
-        "6. 验证播放自动暂停"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 6,
-      "description": "播放速度记忆 - 用户偏好API",
-      "backend_test_steps": [
-        "1. curl -X POST http://localhost:3000/api/user/preferences -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"playSpeed\":1.5}' - 验证创建用户偏好",
-        "2. curl http://localhost:3000/api/user/preferences?userId=test-user - 验证查询用户偏好",
-        "3. curl -X PUT http://localhost:3000/api/user/preferences -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"playSpeed\":2.0}' - 验证更新播放速度",
-        "4. curl -X PUT http://localhost:3000/api/user/preferences -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"playSpeed\":1.0}' - 恢复默认速度"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 调整播放速度为1.5x",
-        "3. 刷新页面",
-        "4. 验证播放速度仍为1.5x"
-      ],
-      "status": "done",
-      "passes": true
-    },
-    {
-      "id": 7,
-      "description": "分享功能 - 微信JSSDK集成",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/share?audioId=audio-001 - 验证生成分享链接接口"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 点击分享按钮",
-        "3. 验证分享面板正常弹出",
-        "4. 验证分享卡片显示标题、封面、简介"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 8,
-      "description": "分享功能 - 分享卡片生成",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/share?audioId=audio-001 - 验证分享信息接口返回正确数据"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 点击分享到微信好友",
-        "3. 验证分享内容包含音频标题",
-        "4. 点击分享到朋友圈",
-        "5. 验证朋友圈分享内容正确"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 9,
-      "description": "加载优化 - 骨架屏组件",
-      "backend_test_steps": [],
-      "frontend_test_steps": [
-        "1. 刷新首页",
-        "2. 验证骨架屏正常显示",
-        "3. 验证数据加载完成后骨架屏消失",
-        "4. 验证内容正常渲染"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 10,
-      "description": "加载优化 - 图片懒加载",
-      "backend_test_steps": [],
-      "frontend_test_steps": [
-        "1. 进入包含多张图片的列表页面",
-        "2. 验证首屏图片正常加载",
-        "3. 滚动页面",
-        "4. 验证下方图片进入可视区时加载",
-        "5. 滚动回顶部",
-        "6. 验证图片已缓存不会重复加载"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 11,
-      "description": "全局搜索 - 搜索API",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/search?q=test - 验证搜索接口",
-        "2. curl http://localhost:3000/api/search?q=故事 - 验证中文搜索",
-        "3. curl http://localhost:3000/api/search?q= - 验证空关键词处理"
-      ],
-      "frontend_test_steps": [
-        "1. 进入search搜索页面",
-        "2. 输入搜索关键词",
-        "3. 验证搜索结果列表正常显示",
-        "4. 验证搜索结果相关性"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 12,
-      "description": "全局搜索 - search页面开发",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/search?q=小说 - 验证搜索结果正确返回"
-      ],
-      "frontend_test_steps": [
-        "1. 进入search搜索页面",
-        "2. 验证搜索历史记录显示",
-        "3. 验证热门推荐显示",
-        "4. 输入关键词进行搜索",
-        "5. 点击搜索结果进入player页面"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 13,
-      "description": "内容分类 - 分类管理API",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/categories - 验证获取分类列表",
-        "2. curl http://localhost:3000/api/categories/1 - 验证获取分类下音频",
-        "3. curl http://localhost:3000/api/categories/999 - 验证无效分类ID处理"
-      ],
-      "frontend_test_steps": [
-        "1. 进入index首页",
-        "2. 验证分类标签显示正确",
-        "3. 点击某个分类",
-        "4. 验证只显示该分类下的音频"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 14,
-      "description": "内容分类 - 分类筛选",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/categories/2 - 验证分类2音频列表"
-      ],
-      "frontend_test_steps": [
-        "1. 在index页面切换不同分类标签",
-        "2. 验证列表内容随分类变化",
-        "3. 验证分类筛选准确性"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 15,
-      "description": "音质选择 - 音质切换逻辑",
-      "backend_test_steps": [
-        "1. curl -X PUT http://localhost:3000/api/user/preferences -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"quality\":\"high\"}' - 验证设置高品质",
-        "2. curl http://localhost:3000/api/user/preferences?userId=test-user - 验证获取偏好确认"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 点击音质选择按钮",
-        "3. 切换到高音质",
-        "4. 验证音质切换生效",
-        "5. 刷新页面验证音质设置已保存"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 16,
-      "description": "离线缓存 - 下载管理",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/downloads - 验证获取下载列表",
-        "2. curl http://localhost:3000/api/downloads/check - 验证检查下载状态"
-      ],
-      "frontend_test_steps": [
-        "1. 进入favorites页面",
-        "2. 选择一个音频点击下载",
-        "3. 验证下载进度显示",
-        "4. 验证下载完成后状态更新"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 17,
-      "description": "离线缓存 - 离线播放",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/downloads - 验证下载列表接口"
-      ],
-      "frontend_test_steps": [
-        "1. 进入favorites页面",
-        "2. 验证已下载音频显示下载状态",
-        "3. 关闭网络连接",
-        "4. 点击已下载音频进行播放",
-        "5. 验证离线播放正常"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 18,
-      "description": "评论打分 - 评论API",
-      "backend_test_steps": [
-        "1. curl -X POST http://localhost:3000/api/comments -H 'Content-Type: application/json' -d '{\"userId\":\"test-user\",\"audioId\":\"audio-004\",\"content\":\"很好听\",\"rating\":5}' - 验证发表评论",
-        "2. curl http://localhost:3000/api/comments/audio-004 - 验证获取评论列表",
-        "3. curl -X POST http://localhost:3000/api/comments -H 'Content-Type: application/json' -d '{\"userId\":\"test-user2\",\"audioId\":\"audio-004\",\"content\":\"不错\",\"rating\":4}' - 添加更多评论"
-      ],
-      "frontend_test_steps": [
-        "1. 打开player页面",
-        "2. 滑动到评论区",
-        "3. 验证评论列表显示",
-        "4. 输入评论内容并打分",
-        "5. 点击提交",
-        "6. 验证新评论显示在列表中"
-      ],
-      "status": "init",
-      "passes": false
-    },
-    {
-      "id": 19,
-      "description": "消息通知 - 通知API",
-      "backend_test_steps": [
-        "1. curl http://localhost:3000/api/notifications - 验证获取通知列表",
-        "2. curl -X POST http://localhost:3000/api/notifications/read -H 'Content-Type: application/json' -d '{\"id\":\"notif-001\"}' - 验证标记已读"
-      ],
-      "frontend_test_steps": [
-        "1. 进入任意页面",
-        "2. 验证通知图标显示",
-        "3. 点击通知图标",
-        "4. 验证通知列表正常显示",
-        "5. 点击某条通知",
-        "6. 验证已读状态更新"
-      ],
-      "status": "init",
-      "passes": false
-    }
-    
-  ]
-}

+ 12 - 0
my-uniapp-vue3/src/pages.json

@@ -61,6 +61,18 @@
         "navigationBarTitleText": "搜索",
         "navigationBarTitleText": "搜索",
         "navigationStyle": "custom"
         "navigationStyle": "custom"
       }
       }
+    },
+    {
+      "path": "pages/album/index",
+      "style": {
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/albums/index",
+      "style": {
+        "navigationStyle": "custom"
+      }
     }
     }
   ],
   ],
   "globalStyle": {
   "globalStyle": {

+ 473 - 0
my-uniapp-vue3/src/pages/album/index.vue

@@ -0,0 +1,473 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-btn" @click="goBack">
+        <text class="nav-icon">←</text>
+      </view>
+      <text class="nav-title">专辑详情</text>
+      <view class="nav-btn" />
+    </view>
+
+    <!-- 骨架屏加载状态 -->
+    <view v-if="loading && !album" class="skeleton-container">
+      <view class="skeleton-cover"></view>
+      <view class="skeleton-info">
+        <view class="skeleton-title"></view>
+        <view class="skeleton-desc"></view>
+      </view>
+    </view>
+
+    <!-- 专辑信息 -->
+    <view v-else-if="album" class="album-header">
+      <view class="album-cover" :style="{ background: getCoverGradient() }">
+        <text class="cover-icon">🎵</text>
+      </view>
+      <view class="album-info">
+        <text class="album-title">{{ album.name }}</text>
+        <text class="album-meta">{{ album.subscriberCount || 0 }}人订阅 · {{ audioList.length }}个音频</text>
+        <text class="album-desc">{{ album.description || '暂无描述' }}</text>
+      </view>
+    </view>
+
+    <!-- 操作按钮 -->
+    <view v-if="album" class="action-bar">
+      <button class="action-btn" :class="{ subscribed: isSubscribed }" @click="toggleSubscribe">
+        <text>{{ isSubscribed ? '已订阅' : '订阅' }}</text>
+      </button>
+      <button class="action-btn primary" @click="playAll">
+        <text>播放全部</text>
+      </button>
+    </view>
+
+    <!-- 音频列表 -->
+    <view v-if="!loading" class="audio-section">
+      <text class="section-title">音频列表</text>
+      <view v-if="audioList.length === 0" class="empty">
+        <text class="empty-icon">🎵</text>
+        <text class="empty-text">暂无音频</text>
+      </view>
+      <view v-else class="audio-list">
+        <view
+          v-for="(item, index) in audioList"
+          :key="item.id"
+          class="audio-item"
+          @click="playAudio(item, index)"
+        >
+          <view class="audio-index">{{ index + 1 }}</view>
+          <view class="audio-content">
+            <text class="audio-title">{{ item.title }}</text>
+            <text class="audio-meta">{{ formatDuration(item.audioDuration) }} · {{ item.wordCount }}字</text>
+          </view>
+          <view class="audio-play">
+            <text class="play-icon">▶</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { onLoad } from '@dcloudio/uni-app';
+import { useAudioStore } from '../../store/audio';
+import { get, post, del } from '../../utils/request';
+
+interface Album {
+  id: number;
+  name: string;
+  description?: string;
+  coverUrl?: string;
+  subscriberCount: number;
+}
+
+interface AudioItem {
+  id: number;
+  title: string;
+  audioDuration: number;
+  wordCount: number;
+  audioUrl: string;
+}
+
+const audioStore = useAudioStore();
+
+const albumId = ref('');
+const album = ref<Album | null>(null);
+const audioList = ref<AudioItem[]>([]);
+const loading = ref(false);
+const isSubscribed = ref(false);
+
+const userId = 1; // TODO: 从用户 store 获取
+
+// 获取封面渐变色
+function getCoverGradient(): string {
+  return 'linear-gradient(135deg, #4f46e5 0%, #818cf8 100%)';
+}
+
+// 格式化时长
+function formatDuration(seconds: number): string {
+  if (!seconds) return '0:00';
+  const mins = Math.floor(seconds / 60);
+  const secs = Math.floor(seconds % 60);
+  return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+// 返回
+function goBack() {
+  uni.navigateBack();
+}
+
+// 加载专辑详情
+async function fetchAlbumDetail() {
+  loading.value = true;
+  try {
+    const result = await get<Album>(`/albums/${albumId.value}`);
+    album.value = result;
+
+    // 检查是否已订阅
+    const subResult = await get<any>(`/albums/${albumId.value}/check-subscribe?userId=${userId}`);
+    isSubscribed.value = subResult?.isSubscribed || false;
+  } catch (error) {
+    console.error('获取专辑详情失败:', error);
+    uni.showToast({ title: '获取专辑详情失败', icon: 'none' });
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 加载专辑音频列表
+async function fetchAudioList() {
+  try {
+    const result = await get<AudioItem[]>(`/albums/${albumId.value}/audios`);
+    audioList.value = result || [];
+  } catch (error) {
+    console.error('获取音频列表失败:', error);
+  }
+}
+
+// 切换订阅状态
+async function toggleSubscribe() {
+  try {
+    if (isSubscribed.value) {
+      await del(`/albums/${albumId.value}/subscribe?userId=${userId}`);
+      isSubscribed.value = false;
+      uni.showToast({ title: '已取消订阅', icon: 'success' });
+    } else {
+      await post(`/albums/${albumId.value}/subscribe`, { userId });
+      isSubscribed.value = true;
+      uni.showToast({ title: '订阅成功', icon: 'success' });
+    }
+  } catch (error) {
+    console.error('订阅操作失败:', error);
+    uni.showToast({ title: '操作失败', icon: 'none' });
+  }
+}
+
+// 播放全部
+function playAll() {
+  if (audioList.value.length > 0) {
+    audioStore.setPlaylist(audioList.value as any, 0, true);
+    playAudio(audioList.value[0], 0);
+  }
+}
+
+// 播放音频
+function playAudio(item: AudioItem, index: number) {
+  audioStore.setPlaylist(audioList.value as any, index, true);
+  audioStore.play(item as any);
+  uni.navigateTo({ url: `/pages/player/index?id=${item.id}` });
+}
+
+// 页面加载
+onLoad(async (options: any) => {
+  albumId.value = options?.id || '';
+  if (albumId.value) {
+    await fetchAlbumDetail();
+    await fetchAudioList();
+  }
+});
+
+onMounted(() => {
+  if (!albumId.value) {
+    const pages = getCurrentPages();
+    const currentPage = pages[pages.length - 1] as any;
+    albumId.value = currentPage?.options?.id || '';
+    if (albumId.value) {
+      fetchAlbumDetail();
+      fetchAudioList();
+    }
+  }
+});
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding-bottom: 32rpx;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 44rpx 32rpx 24rpx;
+  background: #ffffff;
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+
+.nav-btn {
+  width: 64rpx;
+  height: 64rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.nav-icon {
+  font-size: 36rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+/* 骨架屏 */
+.skeleton-container {
+  padding: 32rpx;
+}
+
+.skeleton-cover {
+  width: 240rpx;
+  height: 240rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 16rpx;
+  margin: 0 auto 32rpx;
+}
+
+.skeleton-info {
+  text-align: center;
+}
+
+.skeleton-title {
+  height: 40rpx;
+  width: 60%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin: 0 auto 16rpx;
+}
+
+.skeleton-desc {
+  height: 24rpx;
+  width: 80%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin: 0 auto;
+}
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+/* 专辑信息 */
+.album-header {
+  padding: 32rpx;
+  background: #ffffff;
+  display: flex;
+  gap: 32rpx;
+}
+
+.album-cover {
+  width: 240rpx;
+  height: 240rpx;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.cover-icon {
+  font-size: 80rpx;
+  opacity: 0.6;
+}
+
+.album-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-width: 0;
+}
+
+.album-title {
+  font-size: 36rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 12rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.album-meta {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 16rpx;
+}
+
+.album-desc {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.5;
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+/* 操作按钮 */
+.action-bar {
+  display: flex;
+  gap: 24rpx;
+  padding: 24rpx 32rpx;
+  background: #ffffff;
+}
+
+.action-btn {
+  flex: 1;
+  height: 80rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f5f5f5;
+  border-radius: 40rpx;
+  border: none;
+  font-size: 28rpx;
+  color: #333;
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
+  color: #ffffff;
+}
+
+.action-btn.subscribed {
+  background: #f0f0f0;
+  color: #999;
+}
+
+.action-btn::after {
+  border: none;
+}
+
+/* 音频列表 */
+.audio-section {
+  margin-top: 16rpx;
+  padding: 24rpx 32rpx;
+  background: #ffffff;
+}
+
+.section-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+  display: block;
+}
+
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 0;
+}
+
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+
+.empty-text {
+  font-size: 28rpx;
+  color: #999;
+}
+
+.audio-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.audio-item {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  padding: 20rpx;
+  background: #f9f9f9;
+  border-radius: 12rpx;
+}
+
+.audio-index {
+  width: 48rpx;
+  height: 48rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #e5e7eb;
+  border-radius: 50%;
+  font-size: 24rpx;
+  color: #666;
+  flex-shrink: 0;
+}
+
+.audio-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.audio-title {
+  font-size: 28rpx;
+  color: #333;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 8rpx;
+}
+
+.audio-meta {
+  font-size: 22rpx;
+  color: #999;
+}
+
+.audio-play {
+  width: 64rpx;
+  height: 64rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.play-icon {
+  font-size: 24rpx;
+  color: #ffffff;
+  margin-left: 4rpx;
+}
+</style>

+ 342 - 0
my-uniapp-vue3/src/pages/albums/index.vue

@@ -0,0 +1,342 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-btn" @click="goBack">
+        <text class="nav-icon">←</text>
+      </view>
+      <text class="nav-title">专辑</text>
+      <view class="nav-btn" />
+    </view>
+
+    <!-- 骨架屏加载状态 -->
+    <view v-if="loading && albumList.length === 0" class="skeleton-grid">
+      <view v-for="i in 6" :key="i" class="skeleton-card">
+        <view class="skeleton-cover"></view>
+        <view class="skeleton-info">
+          <view class="skeleton-title"></view>
+          <view class="skeleton-meta"></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 专辑列表 -->
+    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-else>
+      <view v-if="albumList.length === 0 && !loading" class="empty">
+        <text class="empty-icon">📚</text>
+        <text class="empty-text">暂无专辑</text>
+        <text class="empty-hint">创建你的第一个专辑吧</text>
+      </view>
+
+      <view v-else class="album-grid">
+        <view
+          v-for="item in albumList"
+          :key="item.id"
+          class="album-card"
+          @click="goToAlbumDetail(item.id)"
+        >
+          <view class="album-cover" :style="{ background: getCoverGradient(item) }">
+            <text class="cover-icon">🎵</text>
+            <view class="album-count">{{ item.subscriberCount || 0 }}人订阅</view>
+          </view>
+          <view class="album-info">
+            <text class="album-title">{{ item.name }}</text>
+            <text class="album-desc">{{ item.description || '暂无描述' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="loading" class="loading">
+        <text>加载中...</text>
+      </view>
+
+      <view v-if="!hasMore && albumList.length > 0" class="no-more">
+        <text>没有更多了</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { get } from '../../utils/request';
+
+interface Album {
+  id: number;
+  name: string;
+  description?: string;
+  coverUrl?: string;
+  subscriberCount: number;
+}
+
+const albumList = ref<Album[]>([]);
+const page = ref(1);
+const pageSize = 20;
+const total = ref(0);
+const loading = ref(false);
+const hasMore = ref(true);
+
+// 音色封面颜色映射
+const voiceColors: Record<string, string[]> = {
+  default: ['#4f46e5', '#818cf8'],
+  red: ['#ef4444', '#f87171'],
+  orange: ['#f97316', '#fb923c'],
+  green: ['#22c55e', '#4ade80'],
+  blue: ['#3b82f6', '#60a5fa'],
+  purple: ['#a855f7', '#c084fc'],
+};
+
+// 获取封面渐变色
+function getCoverGradient(item: Album): string {
+  // 使用 ID 映射到不同的颜色
+  const colorKeys = Object.keys(voiceColors);
+  const colorKey = colorKeys[item.id % colorKeys.length] || 'default';
+  const colors = voiceColors[colorKey];
+  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
+}
+
+// 返回
+function goBack() {
+  uni.navigateBack();
+}
+
+// 跳转专辑详情
+function goToAlbumDetail(albumId: number) {
+  uni.navigateTo({ url: `/pages/album/index?id=${albumId}` });
+}
+
+// 获取专辑列表
+async function fetchAlbumList(isLoadMore = false) {
+  if (loading.value) return;
+  if (!isLoadMore) {
+    page.value = 1;
+  }
+
+  loading.value = true;
+  try {
+    const result = await get<{ list: Album[]; total: number; totalPages: number }>('/albums', {
+      page: page.value,
+      pageSize,
+    });
+
+    if (isLoadMore) {
+      albumList.value.push(...result.list);
+    } else {
+      albumList.value = result.list;
+    }
+    total.value = result.total;
+    hasMore.value = page.value < result.totalPages;
+  } catch (error) {
+    console.error('获取专辑列表失败:', error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 加载更多
+function loadMore() {
+  if (hasMore.value && !loading.value) {
+    page.value++;
+    fetchAlbumList(true);
+  }
+}
+
+onMounted(async () => {
+  await fetchAlbumList();
+});
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 44rpx 32rpx 24rpx;
+  background: #ffffff;
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+
+.nav-btn {
+  width: 64rpx;
+  height: 64rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.nav-icon {
+  font-size: 36rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.album-list {
+  height: calc(100vh - 100rpx);
+  padding: 24rpx;
+}
+
+/* 骨架屏 */
+.skeleton-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+  padding: 24rpx;
+}
+
+.skeleton-card {
+  width: calc(50% - 10rpx);
+  background: #ffffff;
+  border-radius: 16rpx;
+  overflow: hidden;
+}
+
+.skeleton-cover {
+  width: 100%;
+  height: 200rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+}
+
+.skeleton-info {
+  padding: 20rpx;
+}
+
+.skeleton-title {
+  height: 28rpx;
+  width: 80%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+  margin-bottom: 12rpx;
+}
+
+.skeleton-meta {
+  height: 22rpx;
+  width: 50%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+  border-radius: 8rpx;
+}
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+/* 专辑列表 */
+.album-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.album-card {
+  width: calc(50% - 10rpx);
+  background: #ffffff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+
+.album-cover {
+  position: relative;
+  width: 100%;
+  height: 200rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.cover-icon {
+  font-size: 80rpx;
+  opacity: 0.6;
+}
+
+.album-count {
+  position: absolute;
+  bottom: 12rpx;
+  right: 12rpx;
+  background: rgba(0, 0, 0, 0.6);
+  color: #ffffff;
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+
+.album-info {
+  padding: 20rpx;
+}
+
+.album-title {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1f2937;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 8rpx;
+}
+
+.album-desc {
+  font-size: 22rpx;
+  color: #9ca3af;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+/* 空状态 */
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 200rpx 0;
+}
+
+.empty-icon {
+  font-size: 120rpx;
+  margin-bottom: 24rpx;
+}
+
+.empty-text {
+  font-size: 32rpx;
+  color: #6b7280;
+  margin-bottom: 12rpx;
+}
+
+.empty-hint {
+  font-size: 26rpx;
+  color: #9ca3af;
+}
+
+/* 加载状态 */
+.loading,
+.no-more {
+  text-align: center;
+  padding: 32rpx;
+}
+
+.loading text,
+.no-more text {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+</style>

+ 149 - 280
my-uniapp-vue3/src/pages/index/index.vue

@@ -3,28 +3,11 @@
     <!-- 搜索栏 -->
     <!-- 搜索栏 -->
     <view class="search-bar" @click="goToSearch">
     <view class="search-bar" @click="goToSearch">
       <text class="search-icon">🔍</text>
       <text class="search-icon">🔍</text>
-      <text class="search-placeholder">搜索有声书...</text>
-    </view>
-
-    <!-- 分类标签栏 -->
-    <view class="category-bar">
-      <scroll-view class="category-scroll" scroll-x="true" :show-scrollbar="false">
-        <view class="category-list">
-          <view
-            v-for="cat in categories"
-            :key="cat.id"
-            class="category-item"
-            :class="{ active: selectedCategory === cat.id }"
-            @click="selectCategory(cat.id)"
-          >
-            <text class="category-name">{{ cat.name }}</text>
-          </view>
-        </view>
-      </scroll-view>
+      <text class="search-placeholder">搜索专辑...</text>
     </view>
     </view>
 
 
     <!-- 骨架屏加载状态 -->
     <!-- 骨架屏加载状态 -->
-    <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore" v-if="loading && audioList.length === 0">
+    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-if="loading && albumList.length === 0">
       <view class="skeleton-grid">
       <view class="skeleton-grid">
         <view v-for="i in 6" :key="i" class="skeleton-card">
         <view v-for="i in 6" :key="i" class="skeleton-card">
           <view class="skeleton-cover"></view>
           <view class="skeleton-cover"></view>
@@ -36,31 +19,29 @@
       </view>
       </view>
     </scroll-view>
     </scroll-view>
 
 
-    <!-- 音频列表 -->
-    <scroll-view scroll-y class="audio-list" @scrolltolower="loadMore" v-else>
-      <view v-if="audioList.length === 0 && !loading" class="empty">
-        <text class="empty-icon">🎵</text>
-        <text class="empty-text">暂无音频</text>
-        <text class="empty-hint">点击下方按钮生成第一个音频</text>
+    <!-- 专辑列表 -->
+    <scroll-view scroll-y class="album-list" @scrolltolower="loadMore" v-else>
+      <view v-if="albumList.length === 0 && !loading" class="empty">
+        <text class="empty-icon">📚</text>
+        <text class="empty-text">暂无专辑</text>
+        <text class="empty-hint">点击下方按钮创建第一个专辑</text>
       </view>
       </view>
 
 
-      <view v-else class="audio-grid">
+      <view v-else class="album-grid">
         <view
         <view
-          v-for="item in audioList"
-          :key="item._id"
-          class="audio-card"
-          @click="playAudio(item)"
+          v-for="item in albumList"
+          :key="item.id"
+          class="album-card"
+          @click="goToAlbumDetail(item)"
         >
         >
-          <view class="audio-cover" :style="{ background: getCoverGradient(item.voiceId) }">
+          <view class="album-cover" :style="{ background: getCoverGradient(item) }">
             <text class="cover-icon">🎵</text>
             <text class="cover-icon">🎵</text>
-            <view class="play-overlay">
-              <text class="play-icon">▶</text>
-            </view>
-            <text class="audio-duration-badge">{{ formatDuration(item.audioDuration) }}</text>
+            <view class="album-count">{{ item.audioCount || 0 }}个音频</view>
           </view>
           </view>
-          <view class="audio-info">
-            <text class="audio-title">{{ item.title }}</text>
-            <text class="audio-meta">{{ item.wordCount }}字 · {{ formatDate(item.createdAt) }}</text>
+          <view class="album-info">
+            <text class="album-title">{{ item.name }}</text>
+            <text class="album-desc">{{ item.description || '暂无描述' }}</text>
+            <text class="album-meta">{{ item.subscriberCount || 0 }}人订阅</text>
           </view>
           </view>
         </view>
         </view>
       </view>
       </view>
@@ -69,13 +50,17 @@
         <text>加载中...</text>
         <text>加载中...</text>
       </view>
       </view>
 
 
-      <view v-if="!hasMore && audioList.length > 0" class="no-more">
+      <view v-if="!hasMore && albumList.length > 0" class="no-more">
         <text>没有更多了</text>
         <text>没有更多了</text>
       </view>
       </view>
     </scroll-view>
     </scroll-view>
 
 
-    <!-- 底部生成按钮 -->
+    <!-- 底部操作按钮 -->
     <view class="bottom-bar">
     <view class="bottom-bar">
+      <button class="action-btn" @click="goToAlbums">
+        <text class="btn-icon">📚</text>
+        <text class="btn-text">管理专辑</text>
+      </button>
       <button class="generate-btn" @click="handleGenerate">
       <button class="generate-btn" @click="handleGenerate">
         <text class="btn-icon">+</text>
         <text class="btn-icon">+</text>
         <text class="btn-text">新建生成</text>
         <text class="btn-text">新建生成</text>
@@ -87,66 +72,45 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { ref, onMounted } from 'vue';
 import { ref, onMounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { onShow } from '@dcloudio/uni-app';
-import { useUserStore } from '../../store/user';
-import { useAudioStore } from '../../store/audio';
 import { get } from '../../utils/request';
 import { get } from '../../utils/request';
-import type { AudioItem } from '../../types';
-
-const userStore = useUserStore();
-const audioStore = useAudioStore();
-
-// 音色封面颜色映射
-const voiceColors: Record<string, string[]> = {
-  cherry: ['#667eea', '#764ba2'],
-  ethan: ['#f093fb', '#f5576c'],
-  serena: ['#4facfe', '#00f2fe'],
-  chelsie: ['#43e97b', '#38f9d7'],
-  momo: ['#fa709a', '#fee140'],
-  vivian: ['#a8edea', '#fed6e3'],
-  moon: ['#5ee7df', '#b490ca'],
-  maia: ['#d299c2', '#fef9d7'],
-  kai: ['#89f7fe', '#66a6ff'],
-  nofish: ['#cd9cf2', '#f6f3ff'],
-};
-
-// 音频列表状态
-const audioList = ref<AudioItem[]>([]);
+
+interface Album {
+  id: number;
+  name: string;
+  description?: string;
+  coverUrl?: string;
+  subscriberCount: number;
+  audioCount: number;
+  isDefault: boolean;
+}
+
+const albumList = ref<Album[]>([]);
 const page = ref(1);
 const page = ref(1);
 const pageSize = 20;
 const pageSize = 20;
 const total = ref(0);
 const total = ref(0);
 const loading = ref(false);
 const loading = ref(false);
 const hasMore = ref(true);
 const hasMore = ref(true);
 
 
-// 分类状态
-interface Category {
-  id: number;
-  name: string;
-}
-const categories = ref<Category[]>([
-  { id: 1, name: '推荐' },
-  { id: 2, name: '故事' },
-  { id: 3, name: '小说' },
-  { id: 4, name: '儿童' },
-  { id: 5, name: '知识' },
-  { id: 6, name: '娱乐' },
-]);
-const selectedCategory = ref(1);
+// 封面颜色映射
+const coverColors = [
+  ['#4f46e5', '#818cf8'],
+  ['#ef4444', '#f87171'],
+  ['#f97316', '#fb923c'],
+  ['#22c55e', '#4ade80'],
+  ['#3b82f6', '#60a5fa'],
+  ['#a855f7', '#c084fc'],
+  ['#ec4899', '#f472b6'],
+  ['#14b8a6', '#2dd4bf'],
+];
 
 
 // 获取封面渐变色
 // 获取封面渐变色
-function getCoverGradient(voiceId: string): string {
-  const colors = voiceColors[voiceId] || ['#667eea', '#764ba2'];
+function getCoverGradient(item: Album): string {
+  const colors = coverColors[item.id % coverColors.length] || coverColors[0];
   return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
   return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
 }
 }
 
 
-// 选择分类
-async function selectCategory(categoryId: number) {
-  selectedCategory.value = categoryId;
-  page.value = 1;
-  await fetchAudioList();
-}
-
-// 获取音频列表
-async function fetchAudioList(isLoadMore = false) {
+// 获取专辑列表
+async function fetchAlbumList(isLoadMore = false) {
   if (loading.value) return;
   if (loading.value) return;
   if (!isLoadMore) {
   if (!isLoadMore) {
     page.value = 1;
     page.value = 1;
@@ -154,21 +118,20 @@ async function fetchAudioList(isLoadMore = false) {
 
 
   loading.value = true;
   loading.value = true;
   try {
   try {
-    // 使用分类API
-    const result = await get<{ list: AudioItem[]; total: number; totalPages: number }>(`/categories/${selectedCategory.value}`, {
+    const result = await get<{ list: Album[]; total: number; totalPages: number }>('/albums', {
       page: page.value,
       page: page.value,
       pageSize,
       pageSize,
     });
     });
 
 
     if (isLoadMore) {
     if (isLoadMore) {
-      audioList.value.push(...result.list);
+      albumList.value.push(...result.list);
     } else {
     } else {
-      audioList.value = result.list;
+      albumList.value = result.list;
     }
     }
     total.value = result.total;
     total.value = result.total;
     hasMore.value = page.value < result.totalPages;
     hasMore.value = page.value < result.totalPages;
   } catch (error) {
   } catch (error) {
-    console.error('获取音频列表失败:', error);
+    console.error('获取专辑列表失败:', error);
   } finally {
   } finally {
     loading.value = false;
     loading.value = false;
   }
   }
@@ -178,40 +141,18 @@ async function fetchAudioList(isLoadMore = false) {
 function loadMore() {
 function loadMore() {
   if (hasMore.value && !loading.value) {
   if (hasMore.value && !loading.value) {
     page.value++;
     page.value++;
-    fetchAudioList(true);
+    fetchAlbumList(true);
   }
   }
 }
 }
 
 
-// 播放音频
-function playAudio(item: AudioItem) {
-  audioStore.play(item);
-  uni.navigateTo({ url: `/pages/player/index?id=${item._id}` });
+// 跳转专辑详情
+function goToAlbumDetail(item: Album) {
+  uni.navigateTo({ url: `/pages/album/index?id=${item.id}` });
 }
 }
 
 
-// 格式化时长
-function formatDuration(seconds: number): string {
-  const mins = Math.floor(seconds / 60);
-  const secs = Math.floor(seconds % 60);
-  return `${mins}:${secs.toString().padStart(2, '0')}`;
-}
-
-// 格式化日期
-function formatDate(dateStr: string): string {
-  const date = new Date(dateStr);
-  const now = new Date();
-  const diff = now.getTime() - date.getTime();
-  const days = Math.floor(diff / (1000 * 60 * 60 * 24));
-
-  if (days === 0) return '今天';
-  if (days === 1) return '昨天';
-  if (days < 7) return `${days}天前`;
-
-  return `${date.getMonth() + 1}/${date.getDate()}`;
-}
-
-// 点击用户头像
-function handleUserClick() {
-  uni.navigateTo({ url: '/pages/mine/index' });
+// 跳转专辑管理页
+function goToAlbums() {
+  uni.navigateTo({ url: '/pages/albums/index' });
 }
 }
 
 
 // 跳转搜索页
 // 跳转搜索页
@@ -226,12 +167,12 @@ function handleGenerate() {
 
 
 // 初始化
 // 初始化
 onMounted(async () => {
 onMounted(async () => {
-  await fetchAudioList();
+  await fetchAlbumList();
 });
 });
 
 
 // 页面显示时刷新
 // 页面显示时刷新
 onShow(() => {
 onShow(() => {
-  fetchAudioList();
+  fetchAlbumList();
 });
 });
 </script>
 </script>
 
 
@@ -257,79 +198,19 @@ onShow(() => {
   z-index: 100;
   z-index: 100;
 }
 }
 
 
-.search-bar-inner {
-  flex: 1;
-  height: 64rpx;
-  background: rgba(255, 255, 255, 0.2);
-  border-radius: 32rpx;
-  display: flex;
-  align-items: center;
-  padding: 0 24rpx;
-}
-
-.search-bar .search-icon {
+.search-icon {
   font-size: 28rpx;
   font-size: 28rpx;
   margin-right: 12rpx;
   margin-right: 12rpx;
 }
 }
 
 
-.search-bar .search-placeholder {
+.search-placeholder {
   font-size: 26rpx;
   font-size: 26rpx;
   color: rgba(255, 255, 255, 0.8);
   color: rgba(255, 255, 255, 0.8);
 }
 }
 
 
-.category-bar {
-  position: fixed;
-  top: 100rpx;
-  left: 0;
-  right: 0;
-  background: #ffffff;
-  z-index: 99;
-  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
-}
-
-.category-scroll {
-  width: 100%;
-  white-space: nowrap;
-}
-
-.category-list {
-  display: inline-flex;
-  gap: 16rpx;
-  padding: 16rpx 24rpx;
-}
-
-.category-item {
-  padding: 12rpx 32rpx;
-  font-size: 28rpx;
-  color: #666;
-  flex-shrink: 0;
-  position: relative;
-}
-
-.category-item.active {
-  color: #4f46e5;
-  font-weight: 600;
-}
-
-.category-item.active::after {
-  content: '';
-  position: absolute;
-  bottom: 0;
-  left: 50%;
-  transform: translateX(-50%);
-  width: 40rpx;
-  height: 6rpx;
-  background: #4f46e5;
-  border-radius: 3rpx;
-}
-
-
-
-
-
-.audio-list {
-  padding-top: 160rpx;
-  height: calc(100vh - 160rpx);
+.album-list {
+  padding-top: 120rpx;
+  height: calc(100vh - 120rpx);
 }
 }
 
 
 .empty {
 .empty {
@@ -340,20 +221,6 @@ onShow(() => {
   padding: 200rpx 0;
   padding: 200rpx 0;
 }
 }
 
 
-.empty .login-btn {
-  margin-top: 32rpx;
-  padding: 16rpx 48rpx;
-  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
-  border-radius: 44rpx;
-  border: none;
-  font-size: 28rpx;
-  color: #ffffff;
-}
-
-.empty .login-btn::after {
-  border: none;
-}
-
 .empty-icon {
 .empty-icon {
   font-size: 120rpx;
   font-size: 120rpx;
   margin-bottom: 24rpx;
   margin-bottom: 24rpx;
@@ -370,14 +237,14 @@ onShow(() => {
   color: #9ca3af;
   color: #9ca3af;
 }
 }
 
 
-.audio-grid {
+.album-grid {
   display: flex;
   display: flex;
   flex-wrap: wrap;
   flex-wrap: wrap;
   padding: 24rpx;
   padding: 24rpx;
   gap: 20rpx;
   gap: 20rpx;
 }
 }
 
 
-.audio-card {
+.album-card {
   width: calc(50% - 10rpx);
   width: calc(50% - 10rpx);
   background: #ffffff;
   background: #ffffff;
   border-radius: 16rpx;
   border-radius: 16rpx;
@@ -385,10 +252,10 @@ onShow(() => {
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
   box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
 }
 }
 
 
-.audio-cover {
+.album-cover {
   position: relative;
   position: relative;
   width: 100%;
   width: 100%;
-  height: 240rpx;
+  height: 200rpx;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
@@ -399,38 +266,7 @@ onShow(() => {
   opacity: 0.6;
   opacity: 0.6;
 }
 }
 
 
-.play-overlay {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background: rgba(0, 0, 0, 0.2);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  opacity: 0;
-  transition: opacity 0.3s;
-}
-
-.audio-card:active .play-overlay {
-  opacity: 1;
-}
-
-.play-icon {
-  width: 80rpx;
-  height: 80rpx;
-  background: rgba(255, 255, 255, 0.9);
-  border-radius: 50%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 32rpx;
-  color: #4f46e5;
-  padding-left: 8rpx;
-}
-
-.audio-duration-badge {
+.album-count {
   position: absolute;
   position: absolute;
   bottom: 12rpx;
   bottom: 12rpx;
   right: 12rpx;
   right: 12rpx;
@@ -441,11 +277,11 @@ onShow(() => {
   border-radius: 8rpx;
   border-radius: 8rpx;
 }
 }
 
 
-.audio-info {
+.album-info {
   padding: 20rpx;
   padding: 20rpx;
 }
 }
 
 
-.audio-title {
+.album-title {
   font-size: 28rpx;
   font-size: 28rpx;
   font-weight: 500;
   font-weight: 500;
   color: #1f2937;
   color: #1f2937;
@@ -456,7 +292,17 @@ onShow(() => {
   margin-bottom: 8rpx;
   margin-bottom: 8rpx;
 }
 }
 
 
-.audio-meta {
+.album-desc {
+  font-size: 22rpx;
+  color: #9ca3af;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 8rpx;
+}
+
+.album-meta {
   font-size: 22rpx;
   font-size: 22rpx;
   color: #9ca3af;
   color: #9ca3af;
 }
 }
@@ -473,43 +319,6 @@ onShow(() => {
   color: #9ca3af;
   color: #9ca3af;
 }
 }
 
 
-.bottom-bar {
-  position: fixed;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  padding: 20rpx 32rpx;
-  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
-  background: #ffffff;
-  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
-}
-
-.generate-btn {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  height: 88rpx;
-  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
-  border-radius: 44rpx;
-  border: none;
-}
-
-.generate-btn::after {
-  border: none;
-}
-
-.btn-icon {
-  font-size: 36rpx;
-  color: #ffffff;
-  margin-right: 8rpx;
-}
-
-.btn-text {
-  font-size: 32rpx;
-  font-weight: 600;
-  color: #ffffff;
-}
-
 /* 骨架屏样式 */
 /* 骨架屏样式 */
 .skeleton-grid {
 .skeleton-grid {
   display: flex;
   display: flex;
@@ -527,7 +336,7 @@ onShow(() => {
 
 
 .skeleton-cover {
 .skeleton-cover {
   width: 100%;
   width: 100%;
-  height: 240rpx;
+  height: 200rpx;
   background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
   background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
   background-size: 200% 100%;
   background-size: 200% 100%;
   animation: shimmer 1.5s infinite;
   animation: shimmer 1.5s infinite;
@@ -560,4 +369,64 @@ onShow(() => {
   0% { background-position: 200% 0; }
   0% { background-position: 200% 0; }
   100% { background-position: -200% 0; }
   100% { background-position: -200% 0; }
 }
 }
+
+/* 底部操作栏 */
+.bottom-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  gap: 20rpx;
+  padding: 20rpx 32rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background: #ffffff;
+  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+
+.action-btn {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  background: #f5f5f5;
+  border-radius: 44rpx;
+  border: none;
+}
+
+.action-btn::after {
+  border: none;
+}
+
+.generate-btn {
+  flex: 2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
+  border-radius: 44rpx;
+  border: none;
+}
+
+.generate-btn::after {
+  border: none;
+}
+
+.btn-icon {
+  font-size: 32rpx;
+  color: #ffffff;
+  margin-right: 8rpx;
+}
+
+.btn-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.action-btn .btn-text {
+  color: #333;
+}
 </style>
 </style>

+ 11 - 0
my-uniapp-vue3/src/pages/mine/index.vue

@@ -43,6 +43,12 @@
         <text class="menu-arrow">›</text>
         <text class="menu-arrow">›</text>
       </view>
       </view>
 
 
+      <view class="menu-item" @click="goAlbums">
+        <text class="menu-icon">📚</text>
+        <text class="menu-text">我的专辑</text>
+        <text class="menu-arrow">›</text>
+      </view>
+
       <view class="menu-item" @click="showAbout">
       <view class="menu-item" @click="showAbout">
         <text class="menu-icon">ℹ️</text>
         <text class="menu-icon">ℹ️</text>
         <text class="menu-text">关于我们</text>
         <text class="menu-text">关于我们</text>
@@ -97,6 +103,11 @@ function goHistory() {
   uni.switchTab({ url: '/pages/history/index' });
   uni.switchTab({ url: '/pages/history/index' });
 }
 }
 
 
+// 跳转专辑页
+function goAlbums() {
+  uni.navigateTo({ url: '/pages/albums/index' });
+}
+
 // 关于我们
 // 关于我们
 function showAbout() {
 function showAbout() {
   uni.showModal({
   uni.showModal({

+ 57 - 5
server/prisma/schema.prisma

@@ -19,14 +19,15 @@ model User {
   lastUsageDate   String    @default("")
   lastUsageDate   String    @default("")
   createdAt       DateTime  @default(now())
   createdAt       DateTime  @default(now())
   updatedAt       DateTime  @updatedAt
   updatedAt       DateTime  @updatedAt
-  
+
   audios          Audio[]
   audios          Audio[]
   orders          Order[]
   orders          Order[]
   playRecords     PlayRecord[]
   playRecords     PlayRecord[]
   preferences     UserPreference?
   preferences     UserPreference?
   favorites       Favorite[]
   favorites       Favorite[]
   comments        Comment[]
   comments        Comment[]
-  
+  albumSubscriptions AlbumSubscription[]
+
   @@index([phone])
   @@index([phone])
   @@index([openid])
   @@index([openid])
 }
 }
@@ -47,19 +48,23 @@ model Audio {
   status          String    @default("processing")
   status          String    @default("processing")
   isFavorite      Boolean   @default(false)
   isFavorite      Boolean   @default(false)
   categoryId      Int?
   categoryId      Int?
+  albumId         Int?      // 专辑ID,没有则归入默认专辑
   createdAt       DateTime  @default(now())
   createdAt       DateTime  @default(now())
   updatedAt       DateTime  @updatedAt
   updatedAt       DateTime  @updatedAt
-  
+
   user            User?     @relation(fields: [userId], references: [id])
   user            User?     @relation(fields: [userId], references: [id])
   category        Category? @relation("CategoryAudios", fields: [categoryId], references: [id])
   category        Category? @relation("CategoryAudios", fields: [categoryId], references: [id])
-  
+  album           Album?    @relation(fields: [albumId], references: [id])
+
   playRecords     PlayRecord[]
   playRecords     PlayRecord[]
   favorites       Favorite[]
   favorites       Favorite[]
   comments        Comment[]
   comments        Comment[]
-  
+  albumAudios     AlbumAudio[]
+
   @@index([userId, createdAt])
   @@index([userId, createdAt])
   @@index([userId, isFavorite])
   @@index([userId, isFavorite])
   @@index([categoryId])
   @@index([categoryId])
+  @@index([albumId])
 }
 }
 
 
 model Order {
 model Order {
@@ -155,3 +160,50 @@ model Notification {
   isRead    Boolean  @default(false)
   isRead    Boolean  @default(false)
   createdAt DateTime @default(now())
   createdAt DateTime @default(now())
 }
 }
+
+model Album {
+  id          Int       @id @default(autoincrement())
+  name        String
+  description String?   @db.Text
+  coverUrl    String?
+  userId      Int?
+  isDefault   Boolean   @default(false) // 是否是默认专辑
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+
+  albumAudios AlbumAudio[]
+  subscriptions AlbumSubscription[]
+  audios       Audio[]
+
+  @@index([userId])
+  @@index([createdAt])
+}
+
+model AlbumAudio {
+  id        Int      @id @default(autoincrement())
+  albumId   Int
+  audioId   Int
+  order     Int      @default(0)
+  addedAt   DateTime @default(now())
+
+  album     Album    @relation(fields: [albumId], references: [id], onDelete: Cascade)
+  audio     Audio    @relation(fields: [audioId], references: [id], onDelete: Cascade)
+
+  @@unique([albumId, audioId])
+  @@index([albumId])
+  @@index([audioId])
+}
+
+model AlbumSubscription {
+  id        Int      @id @default(autoincrement())
+  userId    Int
+  albumId   Int
+  createdAt DateTime @default(now())
+
+  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  album     Album    @relation(fields: [albumId], references: [id], onDelete: Cascade)
+
+  @@unique([userId, albumId])
+  @@index([userId])
+  @@index([albumId])
+}

+ 2 - 0
server/src/app.ts

@@ -21,6 +21,7 @@ import searchRoutes from './modules/search/search.controller';
 import categoriesRoutes from './modules/categories/categories.controller';
 import categoriesRoutes from './modules/categories/categories.controller';
 import commentsRoutes from './modules/comments/comments.controller';
 import commentsRoutes from './modules/comments/comments.controller';
 import notificationsRoutes from './modules/notifications/notifications.controller';
 import notificationsRoutes from './modules/notifications/notifications.controller';
+import albumsRoutes from './modules/albums/albums.controller';
 
 
 const app = new Koa();
 const app = new Koa();
 const router = new Router();
 const router = new Router();
@@ -56,6 +57,7 @@ router.use('/api/search', searchRoutes.routes());
 router.use('/api/categories', categoriesRoutes.routes());
 router.use('/api/categories', categoriesRoutes.routes());
 router.use('/api/comments', commentsRoutes.routes());
 router.use('/api/comments', commentsRoutes.routes());
 router.use('/api/notifications', notificationsRoutes.routes());
 router.use('/api/notifications', notificationsRoutes.routes());
+router.use('/api/albums', albumsRoutes.routes());
 
 
 app.use(router.routes()).use(router.allowedMethods());
 app.use(router.routes()).use(router.allowedMethods());
 
 

+ 377 - 0
server/src/modules/albums/albums.controller.ts

@@ -0,0 +1,377 @@
+import Router from '@koa/router';
+import { albumsService } from './albums.service';
+
+const router = new Router();
+
+/**
+ * 获取或创建默认专辑
+ * GET /api/albums/default
+ */
+router.get('/default', async (ctx) => {
+  try {
+    const defaultAlbum = await albumsService.getOrCreateDefaultAlbum();
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: defaultAlbum,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '获取默认专辑失败',
+    };
+  }
+});
+
+/**
+ * 创建专辑
+ * POST /api/albums
+ */
+router.post('/', async (ctx) => {
+  try {
+    const data = ctx.request.body as {
+      name: string;
+      description?: string;
+      coverUrl?: string;
+      userId?: number;
+    };
+
+    if (!data.name) {
+      ctx.body = { code: 400, message: '专辑名称不能为空' };
+      return;
+    }
+
+    const album = await albumsService.createAlbum(data);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: album,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '创建专辑失败',
+    };
+  }
+});
+
+/**
+ * 获取专辑列表
+ * GET /api/albums
+ */
+router.get('/', async (ctx) => {
+  try {
+    const { page, pageSize } = ctx.query as {
+      page?: string;
+      pageSize?: string;
+    };
+
+    const result = await albumsService.getAlbums({
+      page: page ? parseInt(page) : 1,
+      pageSize: pageSize ? parseInt(pageSize) : 20,
+    });
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: result,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '获取专辑列表失败',
+    };
+  }
+});
+
+/**
+ * 获取专辑详情
+ * GET /api/albums/:id
+ */
+router.get('/:id', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    const album = await albumsService.getAlbumById(parseInt(id));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: album,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '获取专辑详情失败',
+    };
+  }
+});
+
+/**
+ * 更新专辑
+ * PUT /api/albums/:id
+ */
+router.put('/:id', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    const data = ctx.request.body as {
+      name?: string;
+      description?: string;
+      coverUrl?: string;
+    };
+
+    const album = await albumsService.updateAlbum(parseInt(id), data);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: album,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '更新专辑失败',
+    };
+  }
+});
+
+/**
+ * 删除专辑
+ * DELETE /api/albums/:id
+ */
+router.delete('/:id', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    await albumsService.deleteAlbum(parseInt(id));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '删除专辑失败',
+    };
+  }
+});
+
+/**
+ * 获取专辑内的音频列表
+ * GET /api/albums/:id/audios
+ */
+router.get('/:id/audios', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    const audios = await albumsService.getAlbumAudios(parseInt(id));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: audios,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '获取专辑音频列表失败',
+    };
+  }
+});
+
+/**
+ * 添加音频到专辑
+ * POST /api/albums/:id/audio
+ */
+router.post('/:id/audio', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    const { audioId, order } = ctx.request.body as {
+      audioId: number;
+      order?: number;
+    };
+
+    if (!audioId) {
+      ctx.body = { code: 400, message: 'audioId 不能为空' };
+      return;
+    }
+
+    const albumAudio = await albumsService.addAudioToAlbum(parseInt(id), audioId, order || 0);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: albumAudio,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '添加音频到专辑失败',
+    };
+  }
+});
+
+/**
+ * 从专辑移除音频
+ * DELETE /api/albums/:id/audio/:audioId
+ */
+router.delete('/:id/audio/:audioId', async (ctx) => {
+  try {
+    const { id, audioId } = ctx.params;
+    await albumsService.removeAudioFromAlbum(parseInt(id), parseInt(audioId));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '从专辑移除音频失败',
+    };
+  }
+});
+
+/**
+ * 调整专辑中音频的顺序
+ * PUT /api/albums/:id/audio/:audioId
+ */
+router.put('/:id/audio/:audioId', async (ctx) => {
+  try {
+    const { id, audioId } = ctx.params;
+    const { order } = ctx.request.body as { order: number };
+
+    if (order === undefined) {
+      ctx.body = { code: 400, message: 'order 不能为空' };
+      return;
+    }
+
+    const albumAudio = await albumsService.reorderAudio(parseInt(id), parseInt(audioId), order);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: albumAudio,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '调整音频顺序失败',
+    };
+  }
+});
+
+/**
+ * 检查订阅状态
+ * GET /api/albums/:id/check-subscribe
+ */
+router.get('/:id/check-subscribe', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    const { userId } = ctx.query as { userId?: string };
+
+    if (!userId) {
+      ctx.body = { code: 400, message: 'userId 不能为空' };
+      return;
+    }
+
+    const isSubscribed = await albumsService.isSubscribed(parseInt(userId), parseInt(id));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { isSubscribed },
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '检查订阅状态失败',
+    };
+  }
+});
+
+/**
+ * 订阅专辑
+ * POST /api/albums/:id/subscribe
+ */
+router.post('/:id/subscribe', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    const { userId } = ctx.request.body as { userId: number };
+
+    if (!userId) {
+      ctx.body = { code: 400, message: 'userId 不能为空' };
+      return;
+    }
+
+    await albumsService.subscribeAlbum(userId, parseInt(id));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '订阅专辑失败',
+    };
+  }
+});
+
+/**
+ * 取消订阅专辑
+ * DELETE /api/albums/:id/subscribe
+ */
+router.delete('/:id/subscribe', async (ctx) => {
+  try {
+    const { id } = ctx.params;
+    const { userId } = ctx.query as { userId?: string };
+
+    if (!userId) {
+      ctx.body = { code: 400, message: 'userId 不能为空' };
+      return;
+    }
+
+    await albumsService.unsubscribeAlbum(parseInt(userId), parseInt(id));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '取消订阅专辑失败',
+    };
+  }
+});
+
+/**
+ * 获取用户已订阅的专辑列表
+ * GET /api/albums/subscribed
+ */
+router.get('/subscribed/list', async (ctx) => {
+  try {
+    const { userId } = ctx.query as { userId?: string };
+
+    if (!userId) {
+      ctx.body = { code: 400, message: 'userId 不能为空' };
+      return;
+    }
+
+    const albums = await albumsService.getSubscribedAlbums(parseInt(userId));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: albums,
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '获取已订阅专辑列表失败',
+    };
+  }
+});
+
+export default router;

+ 338 - 0
server/src/modules/albums/albums.service.ts

@@ -0,0 +1,338 @@
+import { prisma } from '../../models';
+
+/**
+ * 专辑服务
+ */
+export class AlbumsService {
+  /**
+   * 获取或创建默认专辑
+   */
+  async getOrCreateDefaultAlbum() {
+    let defaultAlbum = await prisma.album.findFirst({
+      where: { isDefault: true },
+    });
+
+    if (!defaultAlbum) {
+      defaultAlbum = await prisma.album.create({
+        data: {
+          name: '默认专辑',
+          description: '包含所有未归类音频的默认专辑',
+          isDefault: true,
+        },
+      });
+    }
+
+    return defaultAlbum;
+  }
+
+  /**
+   * 创建专辑
+   */
+  async createAlbum(data: { name: string; description?: string; coverUrl?: string; userId?: number }) {
+    return prisma.album.create({
+      data: {
+        name: data.name,
+        description: data.description,
+        coverUrl: data.coverUrl,
+        userId: data.userId,
+      },
+    });
+  }
+
+  /**
+   * 获取专辑列表
+   */
+  async getAlbums(options?: { page?: number; pageSize?: number }) {
+    const { page = 1, pageSize = 20 } = options || {};
+
+    const [albums, total] = await Promise.all([
+      prisma.album.findMany({
+        skip: (page - 1) * pageSize,
+        take: pageSize,
+        orderBy: { createdAt: 'desc' },
+        include: {
+          _count: {
+            select: { subscriptions: true, albumAudios: true },
+          },
+        },
+      }),
+      prisma.album.count(),
+    ]);
+
+    // 获取所有未分类的音频数量
+    const uncategorizedCount = await prisma.audio.count({
+      where: { albumId: null, status: 'completed' },
+    });
+
+    return {
+      list: albums.map((album) => {
+        // 默认专辑需要加上未分类音频的数量
+        const audioCount = album.isDefault
+          ? album._count?.albumAudios + uncategorizedCount
+          : album._count?.albumAudios;
+
+        return {
+          ...album,
+          subscriberCount: album._count?.subscriptions || 0,
+          audioCount: audioCount || 0,
+          _count: undefined,
+        };
+      }),
+      total,
+      page,
+      pageSize,
+      totalPages: Math.ceil(total / pageSize),
+    };
+  }
+
+  /**
+   * 获取专辑详情
+   */
+  async getAlbumById(albumId: number) {
+    const album = await prisma.album.findUnique({
+      where: { id: albumId },
+      include: {
+        _count: {
+          select: { subscriptions: true },
+        },
+      },
+    });
+
+    if (!album) {
+      throw new Error('专辑不存在');
+    }
+
+    return {
+      ...album,
+      subscriberCount: album._count?.subscriptions || 0,
+      _count: undefined,
+    };
+  }
+
+  /**
+   * 更新专辑
+   */
+  async updateAlbum(albumId: number, data: { name?: string; description?: string; coverUrl?: string }) {
+    return prisma.album.update({
+      where: { id: albumId },
+      data: {
+        name: data.name,
+        description: data.description,
+        coverUrl: data.coverUrl,
+      },
+    });
+  }
+
+  /**
+   * 删除专辑
+   */
+  async deleteAlbum(albumId: number) {
+    await prisma.album.delete({
+      where: { id: albumId },
+    });
+  }
+
+  /**
+   * 获取专辑内的音频列表
+   */
+  async getAlbumAudios(albumId: number) {
+    // 检查是否是默认专辑
+    const album = await prisma.album.findUnique({ where: { id: albumId } });
+    const isDefault = album?.isDefault || false;
+
+    // 获取通过 albumId 直接关联的音频
+    const directAudios = await prisma.audio.findMany({
+      where: { albumId },
+      orderBy: { createdAt: 'desc' },
+    });
+
+    // 获取通过 AlbumAudio 中间表关联的音频
+    const albumAudios = await prisma.albumAudio.findMany({
+      where: { albumId },
+      include: {
+        audio: true,
+      },
+      orderBy: { order: 'asc' },
+    });
+
+    // 如果是默认专辑,还需要获取 albumId 为 null 的音频
+    let uncategorizedAudios: any[] = [];
+    if (isDefault) {
+      uncategorizedAudios = await prisma.audio.findMany({
+        where: {
+          albumId: null,
+          status: 'completed',
+        },
+        orderBy: { createdAt: 'desc' },
+      });
+    }
+
+    // 合并所有音频并去重
+    const audioMap = new Map<number, any>();
+
+    // 先添加直接关联的音频
+    directAudios.forEach((audio) => {
+      audioMap.set(audio.id, audio);
+    });
+
+    // 添加中间表关联的音频
+    albumAudios.forEach((aa) => {
+      audioMap.set(aa.audio.id, {
+        ...aa.audio,
+        order: aa.order,
+        addedAt: aa.addedAt,
+      });
+    });
+
+    // 添加未分类音频
+    uncategorizedAudios.forEach((audio) => {
+      if (!audioMap.has(audio.id)) {
+        audioMap.set(audio.id, audio);
+      }
+    });
+
+    return Array.from(audioMap.values()).sort((a, b) => {
+      // 按添加时间排序,如果没有添加时间则按创建时间
+      const timeA = a.addedAt || a.createdAt;
+      const timeB = b.addedAt || b.createdAt;
+      return new Date(timeB).getTime() - new Date(timeA).getTime();
+    });
+  }
+
+  /**
+   * 添加音频到专辑
+   */
+  async addAudioToAlbum(albumId: number, audioId: number, order: number = 0) {
+    // 检查专辑是否存在
+    const album = await prisma.album.findUnique({ where: { id: albumId } });
+    if (!album) {
+      throw new Error('专辑不存在');
+    }
+
+    // 检查音频是否存在
+    const audio = await prisma.audio.findUnique({ where: { id: audioId } });
+    if (!audio) {
+      throw new Error('音频不存在');
+    }
+
+    return prisma.albumAudio.create({
+      data: {
+        albumId,
+        audioId,
+        order,
+      },
+    });
+  }
+
+  /**
+   * 从专辑移除音频
+   */
+  async removeAudioFromAlbum(albumId: number, audioId: number) {
+    await prisma.albumAudio.delete({
+      where: {
+        albumId_audioId: {
+          albumId,
+          audioId,
+        },
+      },
+    });
+  }
+
+  /**
+   * 调整专辑中音频的顺序
+   */
+  async reorderAudio(albumId: number, audioId: number, order: number) {
+    return prisma.albumAudio.update({
+      where: {
+        albumId_audioId: {
+          albumId,
+          audioId,
+        },
+      },
+      data: { order },
+    });
+  }
+
+  /**
+   * 订阅专辑
+   */
+  async subscribeAlbum(userId: number, albumId: number) {
+    // 检查专辑是否存在
+    const album = await prisma.album.findUnique({ where: { id: albumId } });
+    if (!album) {
+      throw new Error('专辑不存在');
+    }
+
+    return prisma.albumSubscription.upsert({
+      where: {
+        userId_albumId: {
+          userId,
+          albumId,
+        },
+      },
+      update: {},
+      create: {
+        userId,
+        albumId,
+      },
+    });
+  }
+
+  /**
+   * 取消订阅专辑
+   */
+  async unsubscribeAlbum(userId: number, albumId: number) {
+    await prisma.albumSubscription.delete({
+      where: {
+        userId_albumId: {
+          userId,
+          albumId,
+        },
+      },
+    });
+  }
+
+  /**
+   * 获取用户已订阅的专辑列表
+   */
+  async getSubscribedAlbums(userId: number) {
+    const subscriptions = await prisma.albumSubscription.findMany({
+      where: { userId },
+      include: {
+        album: {
+          include: {
+            _count: {
+              select: { subscriptions: true },
+            },
+          },
+        },
+      },
+      orderBy: { createdAt: 'desc' },
+    });
+
+    return subscriptions.map((sub) => ({
+      ...sub.album,
+      subscriberCount: sub.album._count?.subscriptions || 0,
+      subscribedAt: sub.createdAt,
+      _count: undefined,
+    }));
+  }
+
+  /**
+   * 检查用户是否已订阅专辑
+   */
+  async isSubscribed(userId: number, albumId: number): Promise<boolean> {
+    const subscription = await prisma.albumSubscription.findUnique({
+      where: {
+        userId_albumId: {
+          userId,
+          albumId,
+        },
+      },
+    });
+    return !!subscription;
+  }
+}
+
+// 导出单例
+export const albumsService = new AlbumsService();