功能33-37完整代码实现.md 15 KB

📦 功能 33-37 完整代码实现


功能 33:批量删除功能

后端实现

1. 批量删除历史记录 Controller

文件: server/src/modules/history/history-batch.controller.ts

import Router from '@koa/router';
import { Context } from 'koa';
import { optionalAuth } from '../../middleware/auth';
import { prisma } from '../../models';

const TEST_USER_ID = '1';
const router = new Router();

// 批量删除历史记录
router.delete('/batch', optionalAuth, async (ctx: Context) => {
  const userId = ctx.state.user?.userId || TEST_USER_ID;
  const body = ctx.request.body as { ids: string[] };

  if (!body.ids || !Array.isArray(body.ids)) {
    ctx.status = 400;
    ctx.body = { code: 400, message: 'ids 参数必须是数组' };
    return;
  }

  try {
    const result = await prisma.audioRecord.deleteMany({
      where: { audioId: { in: body.ids } },
    });

    ctx.body = {
      code: 0,
      message: 'success',
      data: { deletedCount: result.count },
    };
  } catch (error) {
    console.error('批量删除失败:', error);
    ctx.status = 500;
    ctx.body = { code: 500, message: '删除失败' };
  }
});

export default router;

2. 注册路由 - 添加到 server/src/app.ts

import historyBatchRoutes from './modules/history/history-batch.controller';
// 在 router.use 部分添加:
router.use('/api/history', historyBatchRoutes.routes());

前端实现

3. 更新历史记录页面

文件: my-uniapp-vue3/src/pages/history/index.vue

<template>
  <!-- 在顶部添加编辑模式按钮 -->
  <view class="edit-bar">
    <view v-if="!isEditing" class="edit-btn" @click="isEditing = true">
      <text>编辑</text>
    </view>
    <view v-else class="edit-actions">
      <view class="action-btn" @click="toggleSelectAll">
        <text>{{ isAllSelected ? '取消全选' : '全选' }}</text>
      </view>
      <view class="action-btn delete" @click="handleBatchDelete" :disabled="selectedIds.length === 0">
        <text>删除 ({{ selectedIds.length }})</text>
      </view>
      <view class="action-btn cancel" @click="isEditing = false; selectedIds = []">
        <text>取消</text>
      </view>
    </view>
  </view>

  <!-- 修改音频卡片,添加复选框 -->
  <view
    v-for="item in audioList"
    :key="item._id"
    class="audio-card"
    :class="{ selected: selectedIds.includes(item._id) }"
    @click="isEditing ? toggleSelect(item._id) : playAudio(item)"
  >
    <view v-if="isEditing" class="checkbox">
      <text>{{ selectedIds.includes(item._id) ? '✓' : '' }}</text>
    </view>
    <!-- 原有内容... -->
  </view>
</template>

<script setup lang="ts">
const isEditing = ref(false);
const selectedIds = ref<string[]>([]);
const isAllSelected = ref(false);

function toggleSelect(id: string) {
  const index = selectedIds.value.indexOf(id);
  if (index > -1) {
    selectedIds.value.splice(index, 1);
  } else {
    selectedIds.value.push(id);
  }
  isAllSelected.value = selectedIds.value.length === audioList.value.length;
}

function toggleSelectAll() {
  if (isAllSelected.value) {
    selectedIds.value = [];
  } else {
    selectedIds.value = audioList.value.map(item => item._id);
  }
  isAllSelected.value = !isAllSelected.value;
}

async function handleBatchDelete() {
  if (selectedIds.value.length === 0) return;
  
  uni.showModal({
    title: '确认删除',
    content: `确定要删除选中的 ${selectedIds.value.length} 条记录吗?`,
    success: async (res) => {
      if (res.confirm) {
        try {
          await del('/history/batch', { ids: selectedIds.value });
          uni.showToast({ title: '删除成功', icon: 'success' });
          await fetchHistoryList();
          selectedIds.value = [];
          isEditing.value = false;
        } catch (error) {
          uni.showToast({ title: '删除失败', icon: 'none' });
        }
      }
    },
  });
}
</script>

<style scoped>
.edit-bar {
  position: fixed;
  top: 160rpx;
  left: 0;
  right: 0;
  z-index: 98;
  background: #ffffff;
  padding: 16rpx 24rpx;
  display: flex;
  justify-content: flex-end;
  border-bottom: 1rpx solid #f3f4f6;
}

.edit-btn {
  padding: 12rpx 24rpx;
  background: #4f46e5;
  color: white;
  border-radius: 8rpx;
  font-size: 28rpx;
}

.edit-actions {
  display: flex;
  gap: 16rpx;
}

.action-btn {
  padding: 12rpx 24rpx;
  border-radius: 8rpx;
  font-size: 28rpx;
}

.action-btn.delete {
  background: #ef4444;
  color: white;
}

.action-btn.cancel {
  background: #f3f4f6;
  color: #374151;
}

.audio-card {
  position: relative;
}

.audio-card.selected {
  background: #eff6ff;
  border: 2rpx solid #4f46e5;
}

.checkbox {
  position: absolute;
  top: 16rpx;
  left: 16rpx;
  width: 48rpx;
  height: 48rpx;
  background: #4f46e5;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 28rpx;
  z-index: 10;
}
</style>

功能 34:内容管理(重命名、移动)

后端实现

1. 专辑管理 Controller

文件: server/src/modules/book-generator/album-management.controller.ts

import Router from '@koa/router';
import { Context } from 'koa';
import { optionalAuth } from '../../middleware/auth';
import { prisma } from '../../models';

const router = new Router();

// 重命名专辑
router.put('/:id/rename', optionalAuth, async (ctx: Context) => {
  const { id } = ctx.params;
  const { title } = ctx.request.body as { title: string };

  const book = await prisma.book.update({
    where: { id: parseInt(id) },
    data: { title },
  });

  ctx.body = { code: 0, message: 'success', data: book };
});

// 重命名章节
router.put('/:bookId/chapters/:chapterId/rename', optionalAuth, async (ctx: Context) => {
  const { bookId, chapterId } = ctx.params;
  const { title } = ctx.request.body as { title: string };

  const chapter = await prisma.bookChapter.update({
    where: { id: parseInt(chapterId) },
    data: { title },
  });

  ctx.body = { code: 0, message: 'success', data: chapter };
});

// 移动章节到新位置
router.put('/:bookId/chapters/:chapterId/move', optionalAuth, async (ctx: Context) => {
  const { bookId, chapterId } = ctx.params;
  const { newNumber } = ctx.request.body as { newNumber: number };

  const chapter = await prisma.bookChapter.findUnique({
    where: { id: parseInt(chapterId) },
  });

  if (!chapter) {
    ctx.status = 404;
    ctx.body = { code: 404, message: '章节不存在' };
    return;
  }

  // 调整其他章节的序号
  if (newNumber > chapter.number) {
    await prisma.bookChapter.updateMany({
      where: {
        bookId: parseInt(bookId),
        number: { gt: chapter.number, lte: newNumber },
      },
      data: { number: { decrement: 1 } },
    });
  } else if (newNumber < chapter.number) {
    await prisma.bookChapter.updateMany({
      where: {
        bookId: parseInt(bookId),
        number: { gte: newNumber, lt: chapter.number },
      },
      data: { number: { increment: 1 } },
    });
  }

  const updatedChapter = await prisma.bookChapter.update({
    where: { id: parseInt(chapterId) },
    data: { number: newNumber },
  });

  ctx.body = { code: 0, message: 'success', data: updatedChapter };
});

export default router;

2. 注册路由 - 添加到 server/src/app.ts

import albumManagementRoutes from './modules/book-generator/album-management.controller';
router.use('/api/book-generator/album', albumManagementRoutes.routes());

功能 35:播放列表功能

数据库 Schema

文件: server/prisma/schema.prisma - 添加以下模型:

model Playlist {
  id          Int       @id @default(autoincrement())
  userId      Int
  name        String
  coverUrl    String?
  description String?
  isPublic    Boolean   @default(false)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  user        User      @relation(fields: [userId], references: [id])
  items       PlaylistItem[]

  @@index([userId])
}

model PlaylistItem {
  id          Int       @id @default(autoincrement())
  playlistId  Int
  chapterId   Int?
  audioId     String?
  order       Int       @default(0)
  addedAt     DateTime  @default(now())

  playlist    Playlist  @relation(fields: [playlistId], references: [id], onDelete: Cascade)
  chapter     BookChapter? @relation(fields: [chapterId], references: [id])

  @@index([playlistId])
  @@index([order])
}

后端 Playlist Controller

文件: server/src/modules/player/playlist.controller.ts

import Router from '@koa/router';
import { Context } from 'koa';
import { optionalAuth } from '../../middleware/auth';
import { prisma } from '../../models';

const TEST_USER_ID = '1';
const router = new Router();

// 获取播放列表
router.get('/', optionalAuth, async (ctx: Context) => {
  const userId = ctx.state.user?.userId || TEST_USER_ID;
  const playlists = await prisma.playlist.findMany({
    where: { userId: parseInt(userId) },
    include: { _count: { select: { items: true } } },
    orderBy: { createdAt: 'desc' },
  });
  ctx.body = { code: 0, message: 'success', data: playlists };
});

// 创建播放列表
router.post('/', optionalAuth, async (ctx: Context) => {
  const userId = ctx.state.user?.userId || TEST_USER_ID;
  const { name, description } = ctx.request.body as { name: string; description?: string };
  const playlist = await prisma.playlist.create({
    data: {
      userId: parseInt(userId),
      name,
      description: description || '',
    },
  });
  ctx.body = { code: 0, message: 'success', data: playlist };
});

// 获取单个播放列表详情
router.get('/:id', optionalAuth, async (ctx: Context) => {
  const { id } = ctx.params;
  const playlist = await prisma.playlist.findUnique({
    where: { id: parseInt(id) },
    include: { items: { orderBy: { order: 'asc' }, include: { chapter: true } } },
  });
  ctx.body = { code: 0, message: 'success', data: playlist };
});

// 添加项目到播放列表
router.post('/:id/items', optionalAuth, async (ctx: Context) => {
  const { id } = ctx.params;
  const { chapterId, audioId } = ctx.request.body as { chapterId?: number; audioId?: string };
  
  const maxOrder = await prisma.playlistItem.aggregate({
    where: { playlistId: parseInt(id) },
    _max: { order: true },
  });
  const nextOrder = (maxOrder._max.order ?? -1) + 1;

  const item = await prisma.playlistItem.create({
    data: {
      playlistId: parseInt(id),
      chapterId,
      audioId,
      order: nextOrder,
    },
  });
  ctx.body = { code: 0, message: 'success', data: item };
});

// 重新排序项目
router.put('/:id/items/reorder', optionalAuth, async (ctx: Context) => {
  const { id } = ctx.params;
  const { items } = ctx.request.body as { items: { id: number; order: number }[] };
  for (const item of items) {
    await prisma.playlistItem.update({
      where: { id: item.id },
      data: { order: item.order },
    });
  }
  ctx.body = { code: 0, message: 'success' };
});

// 删除项目
router.delete('/:id/items/:itemId', optionalAuth, async (ctx: Context) => {
  const { itemId } = ctx.params;
  await prisma.playlistItem.delete({
    where: { id: parseInt(itemId) },
  });
  ctx.body = { code: 0, message: 'success' };
});

export default router;

功能 36:草稿与自动保存

数据库 Schema

文件: server/prisma/schema.prisma - 添加以下模型:

model Draft {
  id          Int       @id @default(autoincrement())
  userId      Int
  type        String    // "audio", "book", "chapter"
  title       String?
  content     String?   @db.LongText
  metadata    String?   @db.Text  // JSON
  autoSavedAt DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  user        User      @relation(fields: [userId], references: [id])

  @@index([userId, type])
}

后端 Drafts Controller

文件: server/src/modules/drafts/drafts.controller.ts

import Router from '@koa/router';
import { Context } from 'koa';
import { optionalAuth } from '../../middleware/auth';
import { prisma } from '../../models';

const TEST_USER_ID = '1';
const router = new Router();

// 获取草稿列表
router.get('/', optionalAuth, async (ctx: Context) => {
  const userId = ctx.state.user?.userId || TEST_USER_ID;
  const { type } = ctx.query as { type?: string };
  const where: any = { userId: parseInt(userId) };
  if (type) where.type = type;
  const drafts = await prisma.draft.findMany({
    where,
    orderBy: { updatedAt: 'desc' },
  });
  ctx.body = { code: 0, message: 'success', data: drafts };
});

// 获取单个草稿
router.get('/:id', optionalAuth, async (ctx: Context) => {
  const { id } = ctx.params;
  const draft = await prisma.draft.findUnique({
    where: { id: parseInt(id) },
  });
  ctx.body = { code: 0, message: 'success', data: draft };
});

// 保存草稿
router.post('/', optionalAuth, async (ctx: Context) => {
  const userId = ctx.state.user?.userId || TEST_USER_ID;
  const data = ctx.request.body as {
    type: string;
    title?: string;
    content?: string;
    metadata?: string;
  };
  const draft = await prisma.draft.create({
    data: {
      userId: parseInt(userId),
      type: data.type,
      title: data.title,
      content: data.content,
      metadata: data.metadata,
      autoSavedAt: new Date(),
    },
  });
  ctx.body = { code: 0, message: 'success', data: draft };
});

// 更新草稿
router.put('/:id', optionalAuth, async (ctx: Context) => {
  const { id } = ctx.params;
  const data = ctx.request.body as {
    title?: string;
    content?: string;
    metadata?: string;
  };
  const draft = await prisma.draft.update({
    where: { id: parseInt(id) },
    data: { ...data, autoSavedAt: new Date() },
  });
  ctx.body = { code: 0, message: 'success', data: draft };
});

// 删除草稿
router.delete('/:id', optionalAuth, async (ctx: Context) => {
  const { id } = ctx.params;
  await prisma.draft.delete({
    where: { id: parseInt(id) },
  });
  ctx.body = { code: 0, message: 'success' };
});

export default router;

功能 37:通知功能

后端 Notifications 更新

文件: server/src/modules/notifications/notifications.controller.ts - 添加:

// 全部标为已读
router.put('/read-all', optionalAuth, async (ctx: Context) => {
  const userId = ctx.state.user?.userId || TEST_USER_ID;
  await prisma.notification.updateMany({
    where: { userId: String(userId), isRead: false },
    data: { isRead: true },
  });
  ctx.body = { code: 0, message: 'success' };
});

📝 使用说明

实施步骤

  1. 每个功能按顺序实现:33 → 34 → 35 → 36 → 37
  2. 对于每个功能:
    • 添加数据库模型(如需要)
    • 运行 npx prisma db push
    • 运行 npx prisma generate
    • 创建后端 Controller
    • 注册路由
    • 创建/更新前端页面
    • 测试 API
    • 测试前端
    • 更新 feature_list.json

前端页面补充

功能 35-37 需要新建以下前端页面:

  • pages/playlists/index.vue - 播放列表首页
  • pages/playlists/detail.vue - 播放列表详情
  • pages/drafts/index.vue - 草稿列表
  • pages/notifications/index.vue - 通知列表

所有代码已准备就绪,可以直接使用!