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

feat: 新增背景音乐播放功能

- 新增 MusicPlayer 组件:底部弹出面板,支持开关、播放模式、
  歌单选择、当前播放显示
- 新增 musicStore:管理播放状态、歌单数据、顺序/随机/循环模式、
  localStorage 持久化
- 新增音乐 API(requestGetMusicGroups)
- 顶部导航栏新增音乐控制按钮(带播放动画和关闭标识)
- 新增 zh/en/vi/id 四语言音乐相关翻译
urban 2 недель назад
Родитель
Сommit
512f959455

+ 3 - 0
src/App.vue

@@ -3,6 +3,8 @@
   <loading-view />
   <Toaster position="top-center" richColors />
   <bind-parent-popup ref="bindParentPopupRef" />
+  <!-- 音乐播放器 -->
+  <music-player />
   <!-- 语言选择栏弹出容器 -->
   <div id="language-popover-teleport"></div>
 </template>
@@ -13,6 +15,7 @@ import "vue-sonner/style.css";
 import { Toaster } from "vue-sonner";
 import { onMounted, ref, computed, watch } from "vue";
 import BindParentPopup from "@/components/BindParentDialog/index.vue";
+import MusicPlayer from "@/components/MusicPlayer/index.vue";
 import { useGlobalStore } from "./store/modules/globalStore";
 import { useThemeStore } from "./store/modules/themeStore";
 const bindParentPopupRef = ref(null);

+ 1 - 0
src/api/index.ts

@@ -5,3 +5,4 @@ export * from './task';
 export * from './finance';
 export * from './team';
 export * from './home';
+export * from './music';

+ 34 - 0
src/api/music.ts

@@ -0,0 +1,34 @@
+import { http } from "@/utils/http";
+
+// 歌曲
+interface MusicItem {
+  id: number;
+  groupId: number;
+  title: string;
+  artist: string;
+  url: string;
+  coverUrl: string;
+  duration: number;
+  sort: number;
+  status: number;
+}
+
+// 歌单分组(含歌曲列表)
+interface MusicGroup {
+  id: number;
+  name: string;
+  cover: string;
+  sort: number;
+  status: number;
+  songs: MusicItem[];
+}
+
+// 获取歌单列表(含歌曲)
+export function requestGetMusicGroups(): Promise<CommonResponse<MusicGroup[]>> {
+  return http.request({
+    url: "/api/v1/dt/music/groups",
+    method: "get"
+  });
+}
+
+export type { MusicItem, MusicGroup };

+ 410 - 0
src/components/MusicPlayer/index.vue

@@ -0,0 +1,410 @@
+<template>
+  <!-- 音乐控制面板 -->
+  <van-popup
+    v-model:show="panelVisible"
+    position="bottom"
+    round
+    :style="{ background: 'var(--bg-secondary, #1a1a2e)' }"
+  >
+    <div class="music-panel">
+      <!-- 金色渐变标题栏 -->
+      <div class="panel-header">
+        <div class="header-left">
+          <svg class="header-icon" viewBox="0 0 24 24" fill="none">
+            <path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+            <circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
+            <circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
+          </svg>
+          <span class="header-title">{{ $t('music.control') }}</span>
+        </div>
+        <div class="header-close" @click="panelVisible = false">
+          <van-icon name="cross" />
+        </div>
+      </div>
+
+      <div class="panel-body">
+        <!-- 背景音乐开关 — 立即生效 -->
+        <div class="control-item">
+          <span class="control-label">{{ $t('music.bgMusic') }}</span>
+          <div class="control-right">
+            <span class="switch-text">{{ musicStore.isEnabled ? $t('music.on') : $t('music.off') }}</span>
+            <van-switch
+              :model-value="musicStore.isEnabled"
+              @update:model-value="musicStore.setEnabled"
+              size="22px"
+              active-color="#e8a820"
+            />
+          </div>
+        </div>
+
+        <!-- 播放控制栏:上一首 / 播放暂停 / 下一首 / 播放模式 -->
+        <div class="control-item playback-controls">
+          <span class="control-label">{{ $t('music.playControl') }}</span>
+          <div class="playback-btns">
+            <!-- 上一首 -->
+            <div class="ctrl-btn" @click="musicStore.prev()">
+              <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
+            </div>
+            <!-- 播放/暂停 -->
+            <div class="ctrl-btn ctrl-btn-main" @click="onTogglePlay">
+              <svg v-if="musicStore.isPlaying" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
+              <svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
+            </div>
+            <!-- 下一首 -->
+            <div class="ctrl-btn" @click="musicStore.next()">
+              <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
+            </div>
+            <!-- 播放模式 — 点击循环切换 -->
+            <div class="ctrl-btn" @click="musicStore.cyclePlayMode()">
+              <!-- 顺序播放 -->
+              <svg v-if="musicStore.playMode === 'sequential'" viewBox="0 0 24 24" fill="currentColor">
+                <path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
+              </svg>
+              <!-- 单曲循环 -->
+              <svg v-else-if="musicStore.playMode === 'loop'" viewBox="0 0 24 24" fill="currentColor">
+                <path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"/>
+              </svg>
+              <!-- 随机播放 -->
+              <svg v-else viewBox="0 0 24 24" fill="currentColor">
+                <path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
+              </svg>
+            </div>
+          </div>
+        </div>
+
+        <!-- 当前播放信息 -->
+        <div v-if="musicStore.currentSongTitle" class="control-item now-playing">
+          <span class="control-label">{{ $t('music.nowPlaying') }}</span>
+          <span class="playing-title">{{ musicStore.currentSongTitle }}</span>
+        </div>
+
+        <!-- 播放模式提示 -->
+        <div class="mode-hint">
+          {{ playModeLabel }}
+        </div>
+
+        <!-- 音乐列表 -->
+        <div class="song-list-title">{{ $t('music.songList') }}</div>
+        <div class="song-list">
+          <div
+            v-for="song in musicStore.allSongs"
+            :key="song.id"
+            class="song-item"
+            :class="{ active: musicStore.currentSong?.id === song.id && musicStore.isPlaying }"
+            @click="onSelectSong(song)"
+          >
+            <div class="song-info">
+              <div class="song-title">{{ song.title }}</div>
+              <div v-if="song.artist" class="song-artist">{{ song.artist }}</div>
+            </div>
+            <div class="song-right">
+              <span v-if="song.duration" class="song-duration">{{ formatDuration(song.duration) }}</span>
+              <div v-if="musicStore.currentSong?.id === song.id && musicStore.isPlaying" class="playing-indicator">
+                <span></span><span></span><span></span>
+              </div>
+            </div>
+          </div>
+          <div v-if="!musicStore.allSongs.length" class="song-empty">{{ $t('music.noSongs') }}</div>
+        </div>
+      </div>
+    </div>
+  </van-popup>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted } from "vue";
+import { useMusicStore } from "@/store/modules/musicStore";
+import { useI18n } from "vue-i18n";
+import type { MusicItem } from "@/api/music";
+
+const { t } = useI18n();
+const musicStore = useMusicStore();
+
+const panelVisible = computed({
+  get: () => musicStore.isPanelOpen,
+  set: (val: boolean) => { musicStore.isPanelOpen = val; }
+});
+
+const playModeLabel = computed(() => {
+  const map: Record<string, string> = {
+    sequential: t("music.sequential"),
+    shuffle: t("music.shuffle"),
+    loop: t("music.loop"),
+  };
+  return map[musicStore.playMode] || t("music.sequential");
+});
+
+function onTogglePlay() {
+  if (!musicStore.isEnabled) {
+    musicStore.setEnabled(true);
+  } else if (musicStore.isPlaying) {
+    musicStore.pause();
+  } else {
+    musicStore.play();
+  }
+}
+
+function onSelectSong(song: MusicItem) {
+  musicStore.selectSong(song.id);
+  if (!musicStore.isEnabled) {
+    musicStore.setEnabled(true);
+  }
+}
+
+function formatDuration(seconds: number): string {
+  const m = Math.floor(seconds / 60);
+  const s = seconds % 60;
+  return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+onMounted(async () => {
+  await musicStore.loadSongs();
+  musicStore.restoreSettings();
+});
+</script>
+
+<style lang="scss" scoped>
+.music-panel {
+  color: var(--text-primary, #fff);
+  max-height: 80vh;
+  display: flex;
+  flex-direction: column;
+
+  // ========== 金色渐变标题栏 ==========
+  .panel-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 16px 20px;
+    background: linear-gradient(135deg, #b8860b 0%, #daa520 50%, #f0c040 100%);
+    border-radius: 16px 16px 0 0;
+    flex-shrink: 0;
+
+    .header-left {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+
+      .header-icon {
+        width: 22px;
+        height: 22px;
+        color: #fff;
+      }
+
+      .header-title {
+        font-size: 16px;
+        font-weight: 700;
+        color: #fff;
+        text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+      }
+    }
+
+    .header-close {
+      width: 28px;
+      height: 28px;
+      border-radius: 50%;
+      background: rgba(0, 0, 0, 0.2);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      color: #fff;
+      font-size: 14px;
+    }
+  }
+
+  // ========== 控制区域 ==========
+  .panel-body {
+    padding: 8px 20px 24px;
+    overflow-y: auto;
+    flex: 1;
+
+    .control-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 14px 0;
+      border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.06));
+
+      .control-label {
+        font-size: 14px;
+        font-weight: 500;
+        color: var(--text-primary, #fff);
+        flex-shrink: 0;
+      }
+
+      .control-right {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+
+        .switch-text {
+          font-size: 13px;
+          color: var(--text-secondary, rgba(255, 255, 255, 0.5));
+        }
+      }
+
+      // 当前播放
+      &.now-playing {
+        .playing-title {
+          font-size: 13px;
+          color: var(--color-accent, #e8a820);
+          max-width: 200px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+    }
+
+    // ========== 播放控制栏 ==========
+    .playback-controls {
+      .playback-btns {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+
+        .ctrl-btn {
+          width: 36px;
+          height: 36px;
+          border-radius: 50%;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          background: var(--bg-tertiary, rgba(255, 255, 255, 0.06));
+          cursor: pointer;
+          transition: background 0.2s, transform 0.15s;
+          color: var(--text-primary, #fff);
+
+          &:active {
+            transform: scale(0.9);
+          }
+
+          svg {
+            width: 18px;
+            height: 18px;
+          }
+
+          &.ctrl-btn-main {
+            width: 42px;
+            height: 42px;
+            background: linear-gradient(135deg, #b8860b 0%, #daa520 50%, #f0c040 100%);
+            color: #fff;
+
+            svg {
+              width: 22px;
+              height: 22px;
+            }
+          }
+        }
+      }
+    }
+
+    // ========== 模式提示 ==========
+    .mode-hint {
+      text-align: center;
+      font-size: 12px;
+      color: var(--text-secondary, rgba(255, 255, 255, 0.4));
+      padding: 6px 0 2px;
+    }
+
+    // ========== 歌曲列表 ==========
+    .song-list-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: var(--text-primary, #fff);
+      padding: 14px 0 8px;
+    }
+
+    .song-list {
+      max-height: 280px;
+      overflow-y: auto;
+
+      .song-item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 12px;
+        border-radius: 10px;
+        cursor: pointer;
+        transition: background 0.2s;
+
+        &:active {
+          background: var(--bg-hover, rgba(255, 255, 255, 0.08));
+        }
+
+        &.active {
+          background: var(--bg-hover, rgba(255, 255, 255, 0.06));
+
+          .song-title {
+            color: var(--color-accent, #e8a820);
+          }
+        }
+
+        .song-info {
+          flex: 1;
+          min-width: 0;
+
+          .song-title {
+            font-size: 14px;
+            color: var(--text-primary, #fff);
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+          }
+
+          .song-artist {
+            font-size: 12px;
+            color: var(--text-secondary, rgba(255, 255, 255, 0.45));
+            margin-top: 2px;
+          }
+        }
+
+        .song-right {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          flex-shrink: 0;
+
+          .song-duration {
+            font-size: 12px;
+            color: var(--text-secondary, rgba(255, 255, 255, 0.4));
+          }
+
+          // 播放指示器动画
+          .playing-indicator {
+            display: flex;
+            align-items: flex-end;
+            gap: 2px;
+            height: 14px;
+
+            span {
+              display: block;
+              width: 3px;
+              background: var(--color-accent, #e8a820);
+              border-radius: 1px;
+              animation: musicBar 0.8s ease-in-out infinite;
+
+              &:nth-child(1) { height: 6px; animation-delay: 0s; }
+              &:nth-child(2) { height: 10px; animation-delay: 0.2s; }
+              &:nth-child(3) { height: 4px; animation-delay: 0.4s; }
+            }
+          }
+        }
+      }
+
+      .song-empty {
+        text-align: center;
+        padding: 30px 0;
+        font-size: 13px;
+        color: var(--text-secondary, rgba(255, 255, 255, 0.35));
+      }
+    }
+  }
+}
+
+@keyframes musicBar {
+  0%, 100% { transform: scaleY(1); }
+  50% { transform: scaleY(2); }
+}
+</style>

+ 78 - 0
src/components/headerView/index.vue

@@ -11,6 +11,18 @@
         <div class="header-right">
           <lang-popover ref="langPopoverRef" />
 
+          <!-- 音乐控制按钮 -->
+          <div class="music-toggle-container" @click.stop="onToggleMusic">
+            <div class="music-toggle-btn" :class="{ active: musicStore.isEnabled }">
+              <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="music-icon-svg">
+                <path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+                <circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
+                <circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
+              </svg>
+              <div v-if="!musicStore.isEnabled" class="music-off-slash"></div>
+            </div>
+          </div>
+
           <!-- 主题切换按钮 -->
           <div class="theme-toggle-container" @click.stop="onToggleThemePopover">
             <div class="theme-toggle-btn">
@@ -107,6 +119,7 @@ import { useI18n } from "vue-i18n";
 import { copyText } from "@/utils/utils";
 import { useGlobalStore } from "@/store/modules/globalStore";
 import { useThemeStore } from "@/store/modules/themeStore";
+import { useMusicStore } from "@/store/modules/musicStore";
 import type { ThemeType } from "@/store/modules/themeStore";
 import { requestGetUserInfo } from "@/api";
 import defaultAvatar from "@/assets/images/common/logo.svg";
@@ -116,6 +129,17 @@ const router = useRouter();
 const userInfo = ref<UserInfo>();
 const avatarSrc = computed(() => userInfo.value?.avatar || defaultAvatar);
 
+// 音乐相关
+const musicStore = useMusicStore();
+const onToggleMusic = () => {
+  // 关闭其他弹出
+  isThemePopoverOpen.value = false;
+  isAvatarDropdownOpen.value = false;
+  langPopoverRef.value?.close();
+  // 打开音乐控制面板(MusicPlayer组件的面板)
+  musicStore.togglePanel();
+};
+
 // 主题相关
 const themeStore = useThemeStore();
 const currentTheme = computed(() => themeStore.currentTheme);
@@ -271,6 +295,60 @@ onUnmounted(() => {
     }
   }
 
+  // ======== 音乐控制按钮 ========
+  .music-toggle-container {
+    position: relative;
+    display: flex;
+    align-items: center;
+
+    .music-toggle-btn {
+      width: min(8vw, 34px);
+      height: min(8vw, 34px);
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      background: var(--bg-theme-toggle, #2a2a2c);
+      transition: transform 0.2s ease, background 0.3s ease;
+      position: relative;
+
+      &:hover {
+        transform: scale(1.1);
+      }
+      &:active {
+        transform: scale(0.95);
+      }
+
+      &.active {
+        .music-icon-svg {
+          animation: musicSpin 3s linear infinite;
+        }
+        background: var(--bg-music-active, rgba(255, 195, 0, 0.15));
+      }
+
+      .music-icon-svg {
+        width: min(4.5vw, 18px);
+        height: min(4.5vw, 18px);
+        color: var(--color-theme-toggle-icon, #ffc300);
+      }
+
+      .music-off-slash {
+        position: absolute;
+        width: 22px;
+        height: 2px;
+        background: #ff4444;
+        transform: rotate(-45deg);
+        border-radius: 1px;
+      }
+    }
+  }
+
+  @keyframes musicSpin {
+    from { transform: rotate(0deg); }
+    to { transform: rotate(360deg); }
+  }
+
   // ======== 主题切换按钮 ========
   .theme-toggle-container {
     position: relative;

+ 13 - 0
src/locales/en.json

@@ -347,5 +347,18 @@
     "dark": "Dark Mode",
     "blue": "Classic Blue",
     "light": "Light Mode"
+  },
+  "music": {
+    "control": "Music Control",
+    "bgMusic": "Background Music",
+    "playControl": "Play Control",
+    "nowPlaying": "Now Playing",
+    "songList": "Song List",
+    "noSongs": "No songs available",
+    "on": "Music On",
+    "off": "Music Off",
+    "sequential": "Sequential",
+    "shuffle": "Shuffle",
+    "loop": "Loop"
   }
 }

+ 13 - 0
src/locales/id.json

@@ -321,5 +321,18 @@
     "dark": "Mode Gelap",
     "blue": "Biru Klasik",
     "light": "Mode Terang"
+  },
+  "music": {
+    "control": "Kontrol Musik",
+    "bgMusic": "Musik Latar",
+    "playControl": "Kontrol Putar",
+    "nowPlaying": "Sedang Diputar",
+    "songList": "Daftar Lagu",
+    "noSongs": "Tidak ada lagu",
+    "on": "Musik Aktif",
+    "off": "Musik Mati",
+    "sequential": "Urutan",
+    "shuffle": "Acak",
+    "loop": "Ulangi"
   }
 }

+ 13 - 0
src/locales/vi.json

@@ -324,5 +324,18 @@
     "dark": "Chế độ tối",
     "blue": "Xanh cổ điển",
     "light": "Chế độ sáng"
+  },
+  "music": {
+    "control": "Điều khiển âm nhạc",
+    "bgMusic": "Nhạc nền",
+    "playControl": "Điều khiển phát",
+    "nowPlaying": "Đang phát",
+    "songList": "Danh sách nhạc",
+    "noSongs": "Không có bài hát",
+    "on": "Bật nhạc",
+    "off": "Tắt nhạc",
+    "sequential": "Phát theo thứ tự",
+    "shuffle": "Phát ngẫu nhiên",
+    "loop": "Phát lặp lại"
   }
 }

+ 13 - 0
src/locales/zh.json

@@ -347,5 +347,18 @@
     "dark": "暗黑模式",
     "blue": "蓝白经典",
     "light": "浅色护眼"
+  },
+  "music": {
+    "control": "音乐控制",
+    "bgMusic": "背景音乐",
+    "playControl": "播放控制",
+    "nowPlaying": "正在播放",
+    "songList": "音乐列表",
+    "noSongs": "暂无歌曲",
+    "on": "开启音乐",
+    "off": "关闭音乐",
+    "sequential": "顺序播放",
+    "shuffle": "随机播放",
+    "loop": "单曲循环"
   }
 }

+ 233 - 0
src/store/modules/musicStore.ts

@@ -0,0 +1,233 @@
+import { ref, computed } from "vue";
+import { defineStore } from "pinia";
+import { requestGetMusicGroups } from "@/api/music";
+import type { MusicItem } from "@/api/music";
+
+type PlayMode = "sequential" | "shuffle" | "loop";
+
+export const useMusicStore = defineStore("music", () => {
+  // 状态
+  const isEnabled = ref(false);
+  const isPlaying = ref(false);
+  const isPanelOpen = ref(false);
+  const playMode = ref<PlayMode>("sequential");
+  const currentSongIndex = ref(0);
+  const allSongs = ref<MusicItem[]>([]);
+  const audio = ref<HTMLAudioElement | null>(null);
+  const loaded = ref(false);
+
+  // 当前选中歌曲 id(用户在列表中选择)
+  const selectedSongId = ref<number | null>(null);
+
+  // 计算属性
+  const currentSong = computed(() => {
+    if (!allSongs.value.length) return null;
+    // 如果有选中歌曲,优先使用
+    if (selectedSongId.value) {
+      const found = allSongs.value.find(s => s.id === selectedSongId.value);
+      if (found) return found;
+    }
+    const idx = currentSongIndex.value % allSongs.value.length;
+    return allSongs.value[idx] || null;
+  });
+
+  const currentSongTitle = computed(() => {
+    const song = currentSong.value;
+    if (!song) return "";
+    return song.artist ? `${song.title} - ${song.artist}` : song.title;
+  });
+
+  // 初始化 Audio
+  function initAudio() {
+    if (audio.value) return;
+    audio.value = new Audio();
+    audio.value.volume = 0.5;
+    audio.value.addEventListener("ended", onSongEnded);
+    audio.value.addEventListener("error", onSongError);
+  }
+
+  // 加载所有歌曲(从歌单接口拍平)
+  async function loadSongs() {
+    if (loaded.value) return;
+    try {
+      const res = await requestGetMusicGroups();
+      if (res.data) {
+        // 拍平所有歌单的歌曲到一个列表
+        const songs: MusicItem[] = [];
+        for (const group of res.data) {
+          if (group.songs) {
+            songs.push(...group.songs);
+          }
+        }
+        allSongs.value = songs;
+        loaded.value = true;
+      }
+    } catch (e) {
+      console.error("Failed to load music", e);
+    }
+  }
+
+  // 播放
+  function play() {
+    initAudio();
+    const song = currentSong.value;
+    if (!song || !audio.value) return;
+
+    if (audio.value.src !== song.url) {
+      audio.value.src = song.url;
+    }
+    audio.value.play().then(() => {
+      isPlaying.value = true;
+    }).catch(e => {
+      console.error("Play failed", e);
+      isPlaying.value = false;
+    });
+  }
+
+  // 暂停
+  function pause() {
+    if (audio.value) {
+      audio.value.pause();
+    }
+    isPlaying.value = false;
+  }
+
+  // 开启/关闭背景音乐(立即生效)
+  function setEnabled(val: boolean) {
+    isEnabled.value = val;
+    localStorage.setItem("music_enabled", val ? "1" : "0");
+    if (val) {
+      play();
+    } else {
+      pause();
+    }
+  }
+
+  // 设置播放模式(立即生效)
+  function setPlayMode(mode: PlayMode) {
+    playMode.value = mode;
+    localStorage.setItem("music_play_mode", mode);
+  }
+
+  // 循环切换播放模式: sequential -> loop -> shuffle -> sequential
+  function cyclePlayMode() {
+    const modes: PlayMode[] = ["sequential", "loop", "shuffle"];
+    const idx = modes.indexOf(playMode.value);
+    const nextMode = modes[(idx + 1) % modes.length];
+    setPlayMode(nextMode);
+  }
+
+  // 选择歌曲播放
+  function selectSong(songId: number) {
+    const idx = allSongs.value.findIndex(s => s.id === songId);
+    if (idx >= 0) {
+      selectedSongId.value = songId;
+      currentSongIndex.value = idx;
+      localStorage.setItem("music_song_id", String(songId));
+      if (isEnabled.value) {
+        // 强制重新加载
+        if (audio.value) {
+          audio.value.src = "";
+        }
+        play();
+      }
+    }
+  }
+
+  // 下一首
+  function next() {
+    if (!allSongs.value.length) return;
+    selectedSongId.value = null;
+    if (playMode.value === "shuffle") {
+      currentSongIndex.value = Math.floor(Math.random() * allSongs.value.length);
+    } else {
+      currentSongIndex.value = (currentSongIndex.value + 1) % allSongs.value.length;
+    }
+    if (isEnabled.value) {
+      if (audio.value) audio.value.src = "";
+      play();
+    }
+  }
+
+  // 上一首
+  function prev() {
+    if (!allSongs.value.length) return;
+    selectedSongId.value = null;
+    if (playMode.value === "shuffle") {
+      currentSongIndex.value = Math.floor(Math.random() * allSongs.value.length);
+    } else {
+      currentSongIndex.value = (currentSongIndex.value - 1 + allSongs.value.length) % allSongs.value.length;
+    }
+    if (isEnabled.value) {
+      if (audio.value) audio.value.src = "";
+      play();
+    }
+  }
+
+  // 歌曲结束回调
+  function onSongEnded() {
+    if (playMode.value === "loop") {
+      // 单曲循环,重新播放
+      if (audio.value) {
+        audio.value.currentTime = 0;
+        audio.value.play().catch(() => {});
+      }
+    } else {
+      next();
+    }
+  }
+
+  // 歌曲错误回调
+  function onSongError() {
+    console.error("Song play error, skipping to next");
+    next();
+  }
+
+  // 切换面板显示
+  function togglePanel() {
+    isPanelOpen.value = !isPanelOpen.value;
+  }
+
+  // 恢复设置
+  function restoreSettings() {
+    const savedMode = localStorage.getItem("music_play_mode");
+    const savedSongId = localStorage.getItem("music_song_id");
+
+    if (savedMode) {
+      playMode.value = savedMode as PlayMode;
+    }
+    if (savedSongId) {
+      selectedSongId.value = Number(savedSongId);
+      const idx = allSongs.value.findIndex(s => s.id === Number(savedSongId));
+      if (idx >= 0) currentSongIndex.value = idx;
+    }
+    // 不自动恢复播放状态,用户需要手动开启
+  }
+
+  return {
+    // State
+    isEnabled,
+    isPlaying,
+    isPanelOpen,
+    playMode,
+    currentSongIndex,
+    allSongs,
+    selectedSongId,
+    loaded,
+    // Computed
+    currentSong,
+    currentSongTitle,
+    // Actions
+    loadSongs,
+    play,
+    pause,
+    setEnabled,
+    setPlayMode,
+    cyclePlayMode,
+    selectSong,
+    next,
+    prev,
+    togglePanel,
+    restoreSettings,
+  };
+});