Просмотр исходного кода

feat: 实现功能34-37前端页面

- 创建playlists/index.vue播放列表首页
- 创建playlists/detail.vue播放列表详情
- 创建drafts/index.vue草稿箱页面
- 创建notifications/index.vue通知页面
MyFramework User 4 месяцев назад
Родитель
Сommit
b3fb2c56ed

+ 282 - 0
my-uniapp-vue3/src/pages/drafts/index.vue

@@ -0,0 +1,282 @@
+<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>
+
+    <scroll-view scroll-y class="content">
+      <view v-if="drafts.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="draft-list">
+        <view
+          v-for="item in drafts"
+          :key="item.id"
+          class="draft-card"
+          @click="restoreDraft(item)"
+        >
+          <view class="draft-header">
+            <text class="draft-title">{{ item.title || '无标题' }}</text>
+            <text class="draft-type">{{ getTypeLabel(item.type) }}</text>
+          </view>
+          <text class="draft-content">{{ item.content || '暂无内容' }}</text>
+          <view class="draft-footer">
+            <text class="draft-time">{{ formatTime(item.updatedAt) }}</text>
+            <view class="draft-actions">
+              <view class="action-btn delete" @click.stop="deleteDraft(item.id)">
+                <text>删除</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="loading" class="loading">
+        <text>加载中...</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { get, del } from '../../utils/request';
+
+const drafts = ref<any[]>([]);
+const loading = ref(false);
+
+function goBack() {
+  uni.navigateBack();
+}
+
+function getTypeLabel(type: string): string {
+  const labels: Record<string, string> = {
+    audio: '音频',
+    book: '书籍',
+    chapter: '章节',
+  };
+  return labels[type] || type;
+}
+
+function formatTime(dateStr: string): string {
+  const date = new Date(dateStr);
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+  const minutes = Math.floor(diff / (1000 * 60));
+  const hours = Math.floor(diff / (1000 * 60 * 60));
+  const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+  if (minutes < 1) return '刚刚';
+  if (minutes < 60) return `${minutes}分钟前`;
+  if (hours < 24) return `${hours}小时前`;
+  if (days < 7) return `${days}天前`;
+
+  return `${date.getMonth() + 1}/${date.getDate()}`;
+}
+
+async function fetchDrafts() {
+  loading.value = true;
+  try {
+    const data = await get('/drafts');
+    drafts.value = data || [];
+  } catch (error) {
+    console.error('获取草稿失败:', error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function restoreDraft(item: any) {
+  uni.showModal({
+    title: '恢复草稿',
+    content: '确定要恢复这个草稿吗?',
+    success: (res) => {
+      if (res.confirm) {
+        uni.setStorageSync('draft', item);
+        uni.showToast({ title: '已恢复,去生成页面查看', icon: 'success' });
+        setTimeout(() => {
+          uni.switchTab({ url: '/pages/create/index' });
+        }, 1500);
+      }
+    },
+  });
+}
+
+async function deleteDraft(id: number) {
+  uni.showModal({
+    title: '确认删除',
+    content: '确定要删除这个草稿吗?',
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await del(`/drafts/${id}`);
+          uni.showToast({ title: '删除成功', icon: 'success' });
+          await fetchDrafts();
+        } catch (error) {
+          uni.showToast({ title: '删除失败', icon: 'none' });
+        }
+      }
+    },
+  });
+}
+
+onMounted(() => {
+  fetchDrafts();
+});
+</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: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  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: 40rpx;
+  color: #ffffff;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.content {
+  padding: 24rpx;
+  height: calc(100vh - 160rpx);
+}
+
+.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;
+  text-align: center;
+  max-width: 500rpx;
+}
+
+.draft-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.draft-card {
+  background: #ffffff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+
+.draft-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+
+.draft-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.draft-type {
+  padding: 4rpx 12rpx;
+  background: #f3f4f6;
+  color: #6b7280;
+  font-size: 22rpx;
+  border-radius: 8rpx;
+  margin-left: 12rpx;
+}
+
+.draft-content {
+  font-size: 26rpx;
+  color: #6b7280;
+  line-height: 1.6;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  margin-bottom: 16rpx;
+}
+
+.draft-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.draft-time {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+
+.action-btn {
+  padding: 8rpx 20rpx;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+}
+
+.action-btn.delete {
+  background: #fef2f2;
+  color: #ef4444;
+}
+
+.loading {
+  text-align: center;
+  padding: 32rpx;
+}
+
+.loading text {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+</style>

+ 292 - 0
my-uniapp-vue3/src/pages/notifications/index.vue

@@ -0,0 +1,292 @@
+<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" @click="markAllRead">
+        <text class="read-all">全部已读</text>
+      </view>
+    </view>
+
+    <scroll-view scroll-y class="content">
+      <view v-if="notifications.length === 0 && !loading" class="empty">
+        <text class="empty-icon">🔔</text>
+        <text class="empty-text">暂无通知</text>
+      </view>
+
+      <view v-else class="notification-list">
+        <view
+          v-for="item in notifications"
+          :key="item.id"
+          class="notification-item"
+          :class="{ unread: !item.isRead }"
+          @click="markAsRead(item)"
+        >
+          <view class="notification-icon" :class="getIconClass(item.type)">
+            <text>{{ getIcon(item.type) }}</text>
+          </view>
+          <view class="notification-content">
+            <text class="notification-title">{{ item.title }}</text>
+            <text class="notification-desc">{{ item.content }}</text>
+            <text class="notification-time">{{ formatTime(item.createdAt) }}</text>
+          </view>
+          <view v-if="!item.isRead" class="unread-dot"></view>
+        </view>
+      </view>
+
+      <view v-if="loading" class="loading">
+        <text>加载中...</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { get, put } from '../../utils/request';
+
+const notifications = ref<any[]>([]);
+const loading = ref(false);
+
+function goBack() {
+  uni.navigateBack();
+}
+
+function getIcon(type: string): string {
+  const icons: Record<string, string> = {
+    audio_completed: '🎵',
+    book_completed: '📖',
+    video_completed: '🎬',
+    member_expiring: '⚠️',
+    member_expired: '❌',
+    daily_reminder: '📅',
+  };
+  return icons[type] || '🔔';
+}
+
+function getIconClass(type: string): string {
+  if (type.includes('completed')) return 'success';
+  if (type.includes('expiring')) return 'warning';
+  if (type.includes('expired')) return 'error';
+  return 'info';
+}
+
+function formatTime(dateStr: string): string {
+  const date = new Date(dateStr);
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+  const minutes = Math.floor(diff / (1000 * 60));
+  const hours = Math.floor(diff / (1000 * 60 * 60));
+  const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+  if (minutes < 1) return '刚刚';
+  if (minutes < 60) return `${minutes}分钟前`;
+  if (hours < 24) return `${hours}小时前`;
+  if (days < 7) return `${days}天前`;
+
+  return `${date.getMonth() + 1}/${date.getDate()}`;
+}
+
+async function fetchNotifications() {
+  loading.value = true;
+  try {
+    const data = await get('/notifications');
+    notifications.value = data || [];
+  } catch (error) {
+    console.error('获取通知失败:', error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function markAsRead(item: any) {
+  if (item.isRead) return;
+
+  try {
+    await put(`/notifications/${item.id}/read`);
+    item.isRead = true;
+  } catch (error) {
+    console.error('标记已读失败:', error);
+  }
+}
+
+async function markAllRead() {
+  const unreadCount = notifications.value.filter((n) => !n.isRead).length;
+  if (unreadCount === 0) {
+    uni.showToast({ title: '没有未读消息', icon: 'none' });
+    return;
+  }
+
+  try {
+    await put('/notifications/read-all');
+    notifications.value.forEach((n) => (n.isRead = true));
+    uni.showToast({ title: '已全部标为已读', icon: 'success' });
+  } catch (error) {
+    uni.showToast({ title: '操作失败', icon: 'none' });
+  }
+}
+
+onMounted(() => {
+  fetchNotifications();
+});
+</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: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  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: 40rpx;
+  color: #ffffff;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.read-all {
+  font-size: 26rpx;
+  color: #ffffff;
+}
+
+.content {
+  padding: 24rpx;
+  height: calc(100vh - 160rpx);
+}
+
+.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;
+}
+
+.notification-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+
+.notification-item {
+  display: flex;
+  gap: 16rpx;
+  padding: 24rpx;
+  background: #ffffff;
+  border-radius: 16rpx;
+  position: relative;
+}
+
+.notification-item.unread {
+  background: #f0f9ff;
+}
+
+.notification-icon {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 40rpx;
+  flex-shrink: 0;
+}
+
+.notification-icon.success {
+  background: #dcfce7;
+}
+
+.notification-icon.warning {
+  background: #fef3c7;
+}
+
+.notification-icon.error {
+  background: #fee2e2;
+}
+
+.notification-icon.info {
+  background: #e0f2fe;
+}
+
+.notification-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.notification-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.notification-desc {
+  font-size: 24rpx;
+  color: #6b7280;
+  line-height: 1.5;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.notification-time {
+  font-size: 22rpx;
+  color: #9ca3af;
+  display: block;
+}
+
+.unread-dot {
+  position: absolute;
+  top: 32rpx;
+  right: 24rpx;
+  width: 16rpx;
+  height: 16rpx;
+  background: #ef4444;
+  border-radius: 50%;
+}
+
+.loading {
+  text-align: center;
+  padding: 32rpx;
+}
+
+.loading text {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+</style>

+ 347 - 0
my-uniapp-vue3/src/pages/playlists/detail.vue

@@ -0,0 +1,347 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-btn" @click="goBack">
+        <text class="nav-icon">←</text>
+      </view>
+      <text class="nav-title">{{ playlist?.name || '播放列表' }}</text>
+      <view class="nav-btn" />
+    </view>
+
+    <scroll-view scroll-y class="content">
+      <view v-if="!loading && playlist" class="playlist-header">
+        <view class="playlist-cover">
+          <text class="cover-icon">🎵</text>
+        </view>
+        <view class="playlist-info">
+          <text class="playlist-name">{{ playlist.name }}</text>
+          <text class="playlist-desc">{{ playlist.description || '暂无描述' }}</text>
+          <text class="playlist-meta">{{ playlist.items?.length || 0 }} 首歌曲</text>
+        </view>
+      </view>
+
+      <view class="action-bar">
+        <view class="action-btn primary" @click="playAll">
+          <text>播放全部</text>
+        </view>
+      </view>
+
+      <view class="audio-section">
+        <text class="section-title">歌曲列表</text>
+        <view v-if="!playlist?.items?.length" 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 playlist.items"
+            :key="item.id"
+            class="audio-item"
+            @click="playItem(item, index)"
+          >
+            <view class="audio-index">{{ index + 1 }}</view>
+            <view class="audio-content">
+              <text class="audio-title">{{ item.chapter?.title || '未知音频' }}</text>
+            </view>
+            <view class="audio-delete" @click.stop="removeItem(item.id)">
+              <text>×</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-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';
+
+const audioStore = useAudioStore();
+const playlistId = ref('');
+const playlist = ref<any>(null);
+const loading = ref(false);
+
+function goBack() {
+  uni.navigateBack();
+}
+
+async function fetchPlaylist() {
+  loading.value = true;
+  try {
+    const data = await get(`/playlists/${playlistId.value}`);
+    playlist.value = data;
+  } catch (error) {
+    console.error('获取播放列表失败:', error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function playAll() {
+  const items = playlist.value?.items?.filter((i: any) => i.chapter?.audioUrl) || [];
+  if (items.length === 0) {
+    uni.showToast({ title: '暂无可播放的音频', icon: 'none' });
+    return;
+  }
+  const playlistAudio = items.map((i: any) => ({
+    id: i.chapter.id,
+    title: i.chapter.title,
+    audioUrl: i.chapter.audioUrl,
+    audioDuration: i.chapter.audioDuration || 0,
+    wordCount: i.chapter.wordCount || 0,
+  }));
+  audioStore.setPlaylist(playlistAudio as any, 0, true);
+  audioStore.play(playlistAudio[0] as any);
+  uni.navigateTo({ url: `/pages/player/index?id=${playlistAudio[0].id}` });
+}
+
+function playItem(item: any, index: number) {
+  if (!item.chapter?.audioUrl) {
+    uni.showToast({ title: '该音频暂不可用', icon: 'none' });
+    return;
+  }
+  const items = playlist.value?.items?.filter((i: any) => i.chapter?.audioUrl) || [];
+  const playlistAudio = items.map((i: any) => ({
+    id: i.chapter.id,
+    title: i.chapter.title,
+    audioUrl: i.chapter.audioUrl,
+    audioDuration: i.chapter.audioDuration || 0,
+    wordCount: i.chapter.wordCount || 0,
+  }));
+  const playIndex = items.findIndex((i: any) => i.id === item.id);
+  audioStore.setPlaylist(playlistAudio as any, playIndex >= 0 ? playIndex : 0, true);
+  audioStore.play({
+    id: item.chapter.id,
+    title: item.chapter.title,
+    audioUrl: item.chapter.audioUrl,
+    audioDuration: item.chapter.audioDuration || 0,
+    wordCount: item.chapter.wordCount || 0,
+  } as any);
+  uni.navigateTo({ url: `/pages/player/index?id=${item.chapter.id}` });
+}
+
+async function removeItem(itemId: number) {
+  uni.showModal({
+    title: '确认移除',
+    content: '确定要从播放列表中移除吗?',
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await del(`/playlists/${playlistId.value}/items/${itemId}`);
+          uni.showToast({ title: '移除成功', icon: 'success' });
+          await fetchPlaylist();
+        } catch (error) {
+          uni.showToast({ title: '移除失败', icon: 'none' });
+        }
+      }
+    },
+  });
+}
+
+onLoad((options: any) => {
+  playlistId.value = options?.id || '';
+  if (playlistId.value) {
+    fetchPlaylist();
+  }
+});
+</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: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  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: 40rpx;
+  color: #ffffff;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.content {
+  padding: 24rpx;
+  height: calc(100vh - 160rpx);
+}
+
+.playlist-header {
+  display: flex;
+  gap: 24rpx;
+  padding: 24rpx;
+  background: #ffffff;
+  border-radius: 16rpx;
+  margin-bottom: 24rpx;
+}
+
+.playlist-cover {
+  width: 200rpx;
+  height: 200rpx;
+  background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.cover-icon {
+  font-size: 80rpx;
+  opacity: 0.8;
+}
+
+.playlist-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-width: 0;
+}
+
+.playlist-name {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 12rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.playlist-desc {
+  font-size: 26rpx;
+  color: #6b7280;
+  margin-bottom: 8rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.playlist-meta {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+
+.action-bar {
+  display: flex;
+  gap: 16rpx;
+  margin-bottom: 24rpx;
+}
+
+.action-btn {
+  flex: 1;
+  height: 80rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  color: #ffffff;
+}
+
+.audio-section {
+  background: #ffffff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+
+.section-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 20rpx;
+  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: #9ca3af;
+}
+
+.audio-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+
+.audio-item {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 16rpx;
+  background: #f9f9f9;
+  border-radius: 12rpx;
+}
+
+.audio-index {
+  width: 40rpx;
+  text-align: center;
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+
+.audio-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.audio-title {
+  font-size: 28rpx;
+  color: #1f2937;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.audio-delete {
+  width: 48rpx;
+  height: 48rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 36rpx;
+  color: #ef4444;
+}
+</style>

+ 368 - 0
my-uniapp-vue3/src/pages/playlists/index.vue

@@ -0,0 +1,368 @@
+<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" @click="showCreateModal = true">
+        <text class="nav-icon">+</text>
+      </view>
+    </view>
+
+    <!-- 播放列表 -->
+    <scroll-view scroll-y class="content">
+      <view v-if="playlists.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="playlist-grid">
+        <view
+          v-for="item in playlists"
+          :key="item.id"
+          class="playlist-card"
+          @click="goToDetail(item.id)"
+        >
+          <view class="playlist-cover">
+            <text class="cover-icon">🎵</text>
+            <view class="count-badge">
+              <text>{{ item._count?.items || 0 }}</text>
+            </view>
+          </view>
+          <view class="playlist-info">
+            <text class="playlist-title">{{ item.name }}</text>
+            <text class="playlist-meta">{{ item.description || '暂无描述' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="loading" class="loading">
+        <text>加载中...</text>
+      </view>
+    </scroll-view>
+
+    <!-- 创建播放列表弹窗 -->
+    <view v-if="showCreateModal" class="modal">
+      <view class="modal-mask" @click="showCreateModal = false"></view>
+      <view class="modal-content">
+        <view class="modal-header">
+          <text class="modal-title">新建播放列表</text>
+        </view>
+        <view class="modal-body">
+          <input
+            v-model="newPlaylistName"
+            class="input"
+            placeholder="请输入播放列表名称"
+            maxlength="50"
+          />
+          <textarea
+            v-model="newPlaylistDesc"
+            class="textarea"
+            placeholder="请输入描述(可选)"
+            maxlength="200"
+          />
+        </view>
+        <view class="modal-footer">
+          <view class="modal-btn cancel" @click="showCreateModal = false">
+            <text>取消</text>
+          </view>
+          <view class="modal-btn confirm" @click="createPlaylist" :disabled="!newPlaylistName.trim()">
+            <text>创建</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { get, post } from '../../utils/request';
+
+const playlists = ref<any[]>([]);
+const loading = ref(false);
+const showCreateModal = ref(false);
+const newPlaylistName = ref('');
+const newPlaylistDesc = ref('');
+
+// 返回
+function goBack() {
+  uni.navigateBack();
+}
+
+// 跳转到详情
+function goToDetail(id: number) {
+  uni.navigateTo({ url: `/pages/playlists/detail?id=${id}` });
+}
+
+// 获取播放列表
+async function fetchPlaylists() {
+  loading.value = true;
+  try {
+    const data = await get('/playlists');
+    playlists.value = data || [];
+  } catch (error) {
+    console.error('获取播放列表失败:', error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 创建播放列表
+async function createPlaylist() {
+  if (!newPlaylistName.value.trim()) return;
+
+  try {
+    await post('/playlists', {
+      name: newPlaylistName.value.trim(),
+      description: newPlaylistDesc.value.trim(),
+    });
+    uni.showToast({ title: '创建成功', icon: 'success' });
+    showCreateModal.value = false;
+    newPlaylistName.value = '';
+    newPlaylistDesc.value = '';
+    await fetchPlaylists();
+  } catch (error) {
+    uni.showToast({ title: '创建失败', icon: 'none' });
+  }
+}
+
+onMounted(() => {
+  fetchPlaylists();
+});
+</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: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  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: 40rpx;
+  color: #ffffff;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.content {
+  padding: 24rpx;
+  height: calc(100vh - 160rpx);
+}
+
+.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;
+}
+
+.playlist-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.playlist-card {
+  width: calc(50% - 10rpx);
+  background: #ffffff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+
+.playlist-cover {
+  position: relative;
+  width: 100%;
+  height: 240rpx;
+  background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.cover-icon {
+  font-size: 80rpx;
+  opacity: 0.8;
+}
+
+.count-badge {
+  position: absolute;
+  bottom: 12rpx;
+  right: 12rpx;
+  background: rgba(0, 0, 0, 0.6);
+  color: #ffffff;
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+
+.playlist-info {
+  padding: 20rpx;
+}
+
+.playlist-title {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1f2937;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 8rpx;
+}
+
+.playlist-meta {
+  font-size: 22rpx;
+  color: #9ca3af;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.loading {
+  text-align: center;
+  padding: 32rpx;
+}
+
+.loading text {
+  font-size: 24rpx;
+  color: #9ca3af;
+}
+
+.modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.modal-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+}
+
+.modal-content {
+  position: relative;
+  width: 85%;
+  max-width: 600rpx;
+  background: #ffffff;
+  border-radius: 24rpx;
+  overflow: hidden;
+}
+
+.modal-header {
+  padding: 32rpx;
+  border-bottom: 1rpx solid #f3f4f6;
+}
+
+.modal-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.modal-body {
+  padding: 32rpx;
+}
+
+.input {
+  width: 100%;
+  height: 80rpx;
+  padding: 0 24rpx;
+  border: 1rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  margin-bottom: 24rpx;
+}
+
+.textarea {
+  width: 100%;
+  min-height: 160rpx;
+  padding: 20rpx 24rpx;
+  border: 1rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+}
+
+.modal-footer {
+  display: flex;
+  border-top: 1rpx solid #f3f4f6;
+}
+
+.modal-btn {
+  flex: 1;
+  height: 96rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 30rpx;
+}
+
+.modal-btn.cancel {
+  color: #6b7280;
+  border-right: 1rpx solid #f3f4f6;
+}
+
+.modal-btn.confirm {
+  color: #4f46e5;
+  font-weight: 600;
+}
+
+.modal-btn.confirm[disabled] {
+  opacity: 0.5;
+}
+</style>