VuePress 集成网易云音乐播放器完整实现

lishihuan大约 12 分钟

VuePress 集成网易云音乐播放器完整实现

本文档详细介绍如何在 VuePress 2 + vuepress-theme-hope 主题中集成一个支持拖拽的网易云音乐播放器

📋 目录


✨ 功能特性

已实现功能

  • 网易云音乐集成 - 支持播放网易云、QQ音乐、酷狗音乐等平台
  • 拖拽移动 - 可自由拖拽到屏幕任意位置
  • 展开/收起 - 支持最小化为悬浮按钮
  • 位置记忆 - 刷新页面后保持上次的位置和状态
  • 歌词显示 - 实时同步显示歌词
  • 播放列表 - 显示完整歌单
  • 响应式设计 - 适配PC端和移动端
  • 暗黑模式 - 自动适配暗黑主题
  • 自定义主题色 - 可自定义播放器颜色

效果预览

展开状态:
┌─────────────────────────────────┐
│ 🎵 拖动              [收起] │ ← 可拖拽的头部
├─────────────────────────────────┤
│   ▶  歌曲名 - 歌手              │
│   ━━━━●━━━━━━━━━━  2:30 / 4:20  │
│   🔊 ━━●━━━━                    │
│                                 │
│   播放列表:                     │
│   1. 歌曲1                       │
│   2. 歌曲2                       │
│   3. 歌曲3                       │
└─────────────────────────────────┘

收起状态:
  ┌──────┐
  │  🎵  │ ← 可拖拽
  └──────┘

🔧 技术选型

核心依赖

依赖包版本作用
aplayer^1.10.1HTML5 音乐播放器
meting^2.0.2音乐平台 API 封装

技术栈

  • VuePress: 2.0.0-beta.61
  • Vue 3: 3.2.47
  • TypeScript: 支持
  • SCSS: 样式预处理器

📦 安装依赖

1. 安装音乐播放器相关包

npm install aplayer meting --save

2. 验证安装

安装完成后,package.json 应该包含:

{
  "dependencies": {
    "aplayer": "^1.10.1",
    "meting": "^2.0.2"
  }
}

💻 核心实现

文件结构

src/.vuepress/
├── components/
│   ├── MusicPlayer.vue              # 音乐播放器核心组件(支持拖拽)
│   └── GlobalMusicPlayer.vue        # 全局包装组件
├── config/
│   └── musicConfig.ts               # 配置文件(可选)
└── client.ts                        # 客户端配置(注册全局组件)

1. 创建核心播放器组件

文件路径: src/.vuepress/components/MusicPlayer.vue

点击展开完整代码(约 445 行)
<template>
  <div 
    class="music-player-wrapper" 
    v-if="isShow"
    :style="playerPosition"
    @mousedown="startDrag"
    @touchstart="startDrag"
  >
    <!-- 音乐播放器容器 -->
    <div class="music-player-container" :class="{ 'minimized': isMinimized, 'dragging': isDragging }">
      <!-- 拖拽手柄 + 折叠/展开按钮 -->
      <div class="player-header" :class="{ 'minimized-header': isMinimized }">
        <div class="drag-handle" :title="'拖拽移动播放器'">
          <i class="iconfont icon-drag"></i>
          <span v-if="!isMinimized" class="drag-text">拖动</span>
        </div>
        <div class="player-toggle" @click.stop="togglePlayer" :title="isMinimized ? '展开播放器' : '收起播放器'">
          <i class="iconfont" :class="isMinimized ? 'icon-music' : 'icon-close'"></i>
        </div>
      </div>
      
      <!-- APlayer 播放器 -->
      <div class="aplayer-container" v-show="!isMinimized" ref="playerRef"></div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import APlayer from 'aplayer';
import 'aplayer/dist/APlayer.min.css';

// 配置项
const props = defineProps({
  playlistId: {
    type: String,
    default: '374466671'
  },
  server: {
    type: String,
    default: 'netease'
  },
  type: {
    type: String,
    default: 'playlist'
  },
  autoplay: {
    type: Boolean,
    default: false
  },
  theme: {
    type: String,
    default: '#b7daff'
  },
  defaultShow: {
    type: Boolean,
    default: true
  },
  metingApi: {
    type: String,
    default: 'https://api.injahow.cn/meting/'
  }
});

const playerRef = ref<HTMLElement | null>(null);
const isMinimized = ref(false);
const isShow = ref(props.defaultShow);
let player: APlayer | null = null;

// 拖拽相关状态
const isDragging = ref(false);
const playerPosition = ref({
  right: '20px',
  bottom: '20px',
  left: 'auto',
  top: 'auto'
});
const dragOffset = ref({ x: 0, y: 0 });

// 初始化播放器
const initPlayer = async () => {
  if (!playerRef.value) return;

  try {
    const metingApiUrl = \`\${props.metingApi}?server=\${props.server}&type=\${props.type}&id=\${props.playlistId}\`;
    
    console.log('正在加载音乐列表...');
    const response = await fetch(metingApiUrl);
    
    if (!response.ok) {
      throw new Error(\`API 请求失败: \${response.status}\`);
    }
    
    const musicList = await response.json();
    
    if (!Array.isArray(musicList) || musicList.length === 0) {
      throw new Error('歌单为空或格式错误');
    }

    console.log(\`成功加载 \${musicList.length} 首歌曲\`);

    // 初始化 APlayer
    player = new APlayer({
      container: playerRef.value,
      audio: musicList,
      autoplay: props.autoplay,
      theme: props.theme,
      loop: 'all',
      order: 'list',
      preload: 'auto',
      volume: 0.7,
      mutex: true,
      listFolded: false,
      listMaxHeight: '250px',
      lrcType: 3,
      storageName: 'vuepress-music-player'
    });

    // 保存播放器状态
    player.on('play', () => {
      localStorage.setItem('music-player-playing', 'true');
    });

    player.on('pause', () => {
      localStorage.setItem('music-player-playing', 'false');
    });

    console.log('音乐播放器初始化成功!');

  } catch (error) {
    console.error('音乐播放器初始化失败:', error);
  }
};

// 切换播放器显示状态
const togglePlayer = () => {
  isMinimized.value = !isMinimized.value;
  localStorage.setItem('music-player-minimized', isMinimized.value.toString());
};

// 开始拖拽
const startDrag = (e: MouseEvent | TouchEvent) => {
  const target = e.target as HTMLElement;
  
  // 如果是收起状态,允许在整个播放器上拖拽
  if (isMinimized.value) {
    if (target.closest('.player-toggle') || target.closest('.aplayer')) {
      return;
    }
  } else {
    // 展开状态,只允许在头部拖拽
    if (!target.closest('.player-header') && !target.closest('.drag-handle')) {
      return;
    }
  }

  isDragging.value = true;
  
  const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
  const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
  
  dragOffset.value = {
    x: clientX,
    y: clientY
  };

  document.addEventListener('mousemove', onDrag);
  document.addEventListener('mouseup', stopDrag);
  document.addEventListener('touchmove', onDrag);
  document.addEventListener('touchend', stopDrag);
  
  e.preventDefault();
};

// 拖拽中
const onDrag = (e: MouseEvent | TouchEvent) => {
  if (!isDragging.value) return;

  const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
  const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;

  const deltaX = clientX - dragOffset.value.x;
  const deltaY = clientY - dragOffset.value.y;

  const wrapper = document.querySelector('.music-player-wrapper') as HTMLElement;
  if (wrapper) {
    const rect = wrapper.getBoundingClientRect();
    const newLeft = rect.left + deltaX;
    const newTop = rect.top + deltaY;

    // 限制在视口范围内
    const maxX = window.innerWidth - rect.width;
    const maxY = window.innerHeight - rect.height;

    const constrainedLeft = Math.max(0, Math.min(newLeft, maxX));
    const constrainedTop = Math.max(0, Math.min(newTop, maxY));

    playerPosition.value = {
      left: \`\${constrainedLeft}px\`,
      top: \`\${constrainedTop}px\`,
      right: 'auto',
      bottom: 'auto'
    };

    dragOffset.value = {
      x: clientX,
      y: clientY
    };
  }
};

// 停止拖拽
const stopDrag = () => {
  if (isDragging.value) {
    isDragging.value = false;
    
    // 保存位置到 localStorage
    localStorage.setItem('music-player-position', JSON.stringify(playerPosition.value));
    
    document.removeEventListener('mousemove', onDrag);
    document.removeEventListener('mouseup', stopDrag);
    document.removeEventListener('touchmove', onDrag);
    document.removeEventListener('touchend', stopDrag);
  }
};

// 恢复保存的位置
const restorePosition = () => {
  try {
    const savedPosition = localStorage.getItem('music-player-position');
    if (savedPosition) {
      playerPosition.value = JSON.parse(savedPosition);
    }
  } catch (error) {
    console.error('恢复播放器位置失败:', error);
  }
};

// 组件挂载
onMounted(() => {
  const savedMinimized = localStorage.getItem('music-player-minimized');
  if (savedMinimized !== null) {
    isMinimized.value = savedMinimized === 'true';
  }

  restorePosition();
  initPlayer();
});

// 组件卸载
onBeforeUnmount(() => {
  document.removeEventListener('mousemove', onDrag);
  document.removeEventListener('mouseup', stopDrag);
  document.removeEventListener('touchmove', onDrag);
  document.removeEventListener('touchend', stopDrag);
  
  if (player) {
    player.destroy();
  }
});
</script>

<style scoped lang="scss">
.music-player-wrapper {
  position: fixed;
  z-index: 9999;
  user-select: none;
  
  .music-player-container {
    background: #fff;
    border-radius: 12px;
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
    overflow: hidden;
    transition: box-shadow 0.3s ease;
    
    &.dragging {
      box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
      cursor: move;
      cursor: grab;
      cursor: -webkit-grab;
    }
    
    &.minimized {
      width: 60px;
      height: 60px;
      cursor: move;
      cursor: grab;
      
      &:active {
        cursor: grabbing;
        cursor: -webkit-grabbing;
      }
      
      .aplayer-container {
        display: none;
      }
      
      .player-header {
        display: none;
      }
      
      .player-toggle {
        position: relative;
        top: 0;
        right: 0;
        width: 100%;
        height: 100%;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        pointer-events: none;
        
        &:hover {
          transform: scale(1.05);
        }
        
        i {
          font-size: 24px;
          color: #fff;
          pointer-events: auto;
        }
      }
    }
    
    // 头部区域(拖拽手柄 + 按钮)
    .player-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 8px 12px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      cursor: move;
      cursor: grab;
      
      &:active {
        cursor: grabbing;
        cursor: -webkit-grabbing;
      }
      
      .drag-handle {
        display: flex;
        align-items: center;
        gap: 6px;
        color: #fff;
        font-size: 14px;
        flex: 1;
        
        i {
          font-size: 16px;
        }
        
        .drag-text {
          font-weight: 500;
          opacity: 0.9;
        }
      }
      
      .player-toggle {
        width: 28px;
        height: 28px;
        background: rgba(255, 255, 255, 0.2);
        border-radius: 50%;
        display: flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        transition: all 0.3s ease;
        flex-shrink: 0;
        
        &:hover {
          background: rgba(255, 255, 255, 0.3);
          transform: scale(1.1);
        }
        
        i {
          font-size: 14px;
          color: #fff;
        }
      }
    }
    
    .aplayer-container {
      width: 350px;
      
      :deep(.aplayer) {
        border-radius: 0 0 12px 12px;
        box-shadow: none;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
        
        .aplayer-list {
          max-height: 250px;
          overflow-y: auto;
          
          &::-webkit-scrollbar {
            width: 6px;
          }
          
          &::-webkit-scrollbar-thumb {
            background: #ddd;
            border-radius: 3px;
            
            &:hover {
              background: #bbb;
            }
          }
        }
      }
    }
  }
}

// 响应式设计
@media (max-width: 768px) {
  .music-player-wrapper {
    .music-player-container {
      &:not(.minimized) .aplayer-container {
        width: calc(100vw - 40px);
        max-width: 350px;
      }
      
      .player-header .drag-text {
        display: none;
      }
    }
  }
}

// 暗黑模式适配
@media (prefers-color-scheme: dark) {
  .music-player-container {
    background: #1e1e1e;
    
    .player-header {
      background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
    }
  }
}
</style>

核心功能解析

拖拽实现原理
// 1. 监听鼠标/触摸事件
@mousedown="startDrag"
@touchstart="startDrag"

// 2. 计算拖拽偏移量
const deltaX = clientX - dragOffset.value.x;
const deltaY = clientY - dragOffset.value.y;

// 3. 更新位置(限制在视口内)
const constrainedLeft = Math.max(0, Math.min(newLeft, maxX));
const constrainedTop = Math.max(0, Math.min(newTop, maxY));

// 4. 保存位置到 localStorage
localStorage.setItem('music-player-position', JSON.stringify(playerPosition.value));
状态持久化
// 保存状态
localStorage.setItem('music-player-minimized', isMinimized.value.toString());
localStorage.setItem('music-player-position', JSON.stringify(playerPosition.value));

// 恢复状态
onMounted(() => {
  restorePosition();
  initPlayer();
});

2. 创建全局包装组件

文件路径: src/.vuepress/components/GlobalMusicPlayer.vue

<template>
  <ClientOnly>
    <MusicPlayer
      v-if="config.enable"
      :playlistId="config.playlistId"
      :server="config.server"
      :type="config.type"
      :autoplay="config.autoplay"
      :theme="config.theme"
      :defaultShow="config.defaultShow"
      :metingApi="config.metingApi"
    />
  </ClientOnly>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import MusicPlayer from './MusicPlayer.vue';

// 默认配置
const config = ref({
  enable: true,
  playlistId: '374466671', // 你的网易云音乐歌单ID
  server: 'netease',
  type: 'playlist',
  autoplay: false,
  theme: '#b7daff',
  defaultShow: true,
  metingApi: 'https://api.injahow.cn/meting/'
});

onMounted(() => {
  try {
    const savedConfig = localStorage.getItem('music-player-config');
    if (savedConfig) {
      config.value = { ...config.value, ...JSON.parse(savedConfig) };
    }
  } catch (error) {
    console.error('读取音乐播放器配置失败:', error);
  }
});
</script>

3. 注册全局组件

文件路径: src/.vuepress/client.ts

在文件顶部添加导入:

import GlobalMusicPlayer from './components/GlobalMusicPlayer.vue';

defineClientConfig 中添加:

export default defineClientConfig({
  enhance: ({ app, router, siteData }) => {
    // ... 其他代码
  },
  rootComponents: [GlobalMusicPlayer], // 添加这一行
});

完整示例:

import { defineClientConfig } from "@vuepress/client";
import GlobalMusicPlayer from './components/GlobalMusicPlayer.vue';

export default defineClientConfig({
  enhance: ({ app, router, siteData }) => {
    // 你的其他配置
  },
  rootComponents: [GlobalMusicPlayer], // 添加全局音乐播放器
});

⚙️ 配置说明

基础配置

修改 GlobalMusicPlayer.vue 中的配置:

const config = ref({
  enable: true,              // 是否启用
  playlistId: '374466671',   // 歌单ID
  server: 'netease',         // 平台
  type: 'playlist',          // 类型
  autoplay: false,           // 自动播放
  theme: '#b7daff',          // 主题色
  defaultShow: true,         // 默认显示
  metingApi: 'https://api.injahow.cn/meting/'  // API地址
});

配置项详解

配置项类型默认值说明
enableBooleantrue是否启用音乐播放器
playlistIdString'374466671'歌单/歌曲ID
serverString'netease'音乐平台:netease(网易云)、tencent(QQ音乐)、kugou(酷狗)
typeString'playlist'类型:song(单曲)、playlist(歌单)、album(专辑)、artist(歌手)
autoplayBooleanfalse是否自动播放(注意浏览器限制)
themeString'#b7daff'播放器主题颜色(支持任何CSS颜色值)
defaultShowBooleantrue是否默认显示播放器
metingApiString'https://...'Meting API 地址

📖 使用方法

1. 获取网易云音乐歌单ID

方法一:网页版

  1. 打开网易云音乐:https://music.163.com/open in new window
  2. 登录你的账号
  3. 找到或创建你的歌单
  4. 点击进入歌单页面
  5. 查看地址栏
示例地址:
https://music.163.com/#/playlist?id=374466671
                                    ^^^^^^^^^
                                    这就是歌单ID

方法二:移动端分享

  1. 打开网易云音乐 APP
  2. 找到歌单,点击分享
  3. 复制链接
  4. 从链接中提取 id= 后面的数字

2. 修改配置

编辑 src/.vuepress/components/GlobalMusicPlayer.vue

const config = ref({
  enable: true,
  playlistId: '你的歌单ID',  // 👈 修改这里
  server: 'netease',
  // ... 其他配置
});

3. 启动项目

npm run docs:dev

4. 使用播放器

  • 拖拽移动:鼠标按住头部区域(或收起状态下的整个图标)拖动
  • 展开/收起:点击右上角的按钮
  • 播放控制:使用播放器内置的控制按钮
  • 位置重置:如果播放器位置不合适,可以清除浏览器 localStorage

🎨 进阶优化

1. 自定义主题色

支持多种主题色方案:

// 淡蓝色(默认)
theme: '#b7daff'

// 渐变紫色
theme: '#764ba2'

// 粉色
theme: '#FF6B9D'

// 橙色
theme: '#FF8C3D'

// 绿色
theme: '#52c41a'

2. 播放不同类型

播放单曲

{
  playlistId: '歌曲ID',
  server: 'netease',
  type: 'song'
}

播放专辑

{
  playlistId: '专辑ID',
  server: 'netease',
  type: 'album'
}

播放歌手

{
  playlistId: '歌手ID',
  server: 'netease',
  type: 'artist'
}

播放QQ音乐

{
  playlistId: 'QQ音乐歌单ID',
  server: 'tencent',
  type: 'playlist'
}

3. 自定义样式

修改 MusicPlayer.vue 中的样式:

// 修改播放器宽度
.aplayer-container {
  width: 400px;  // 默认 350px
}

// 修改圆角
.music-player-container {
  border-radius: 16px;  // 默认 12px
}

// 修改头部背景
.player-header {
  background: linear-gradient(135deg, #your-color1 0%, #your-color2 100%);
}

4. 自建 Meting API

如果默认 API 不稳定,推荐自建:

使用 Docker 部署

# 1. 拉取镜像
docker pull injahow/meting-api

# 2. 运行容器
docker run -d \
  --name meting-api \
  -p 3000:3000 \
  injahow/meting-api

# 3. 测试 API
curl http://localhost:3000/?server=netease&type=playlist&id=374466671

修改配置

{
  metingApi: 'http://你的服务器IP:3000/'
}

参考资源


❓ 常见问题

1. 播放器不显示?

排查步骤:

  1. 检查浏览器控制台是否有错误
  2. 确认 enable: true
  3. 检查组件是否正确注册到 client.ts
  4. 清除浏览器缓存:Ctrl + Shift + Delete
  5. 重启开发服务器

检查命令:

# 清理缓存
npm run docs:clean-dev

# 重新启动
npm run docs:dev

2. 歌单加载失败?

可能原因:

  • ❌ 歌单ID错误
  • ❌ 歌单设置为私密
  • ❌ 网络问题
  • ❌ API 不可用

解决方案:

// 1. 确认歌单ID正确
// 正确格式:纯数字,如 '374466671'

// 2. 确保歌单是公开的
// 在网易云音乐中设置歌单为公开

// 3. 测试 API 是否可用
fetch('https://api.injahow.cn/meting/?server=netease&type=playlist&id=374466671')
  .then(res => res.json())
  .then(data => console.log(data));

// 4. 尝试其他歌单
playlistId: '60198' // 网易云热歌榜

3. 拖拽不流畅?

优化建议:

// 添加硬件加速
.music-player-wrapper {
  transform: translateZ(0);
  will-change: transform;
}

// 优化过渡动画
.music-player-container {
  transition: none; // 拖拽时禁用过渡
}

4. 歌曲无法播放?

常见原因:

  1. 版权限制 - 某些歌曲有地区限制
  2. VIP 歌曲 - 需要会员才能播放
  3. 下架歌曲 - 歌曲已从平台移除

解决方案:

  • 更换其他歌曲
  • 使用无版权限制的歌单
  • 尝试其他音乐平台

5. 移动端显示异常?

检查响应式样式:

@media (max-width: 768px) {
  .aplayer-container {
    width: calc(100vw - 40px) !important;
    max-width: 350px;
  }
}

6. 播放器位置异常?

重置位置:

// 在浏览器控制台执行
localStorage.removeItem('music-player-position');
localStorage.removeItem('music-player-minimized');

// 然后刷新页面
location.reload();

7. 构建后不显示?

检查构建配置:

// vite.config.ts 或 config.ts
export default {
  build: {
    // 确保不忽略音乐播放器资源
    rollupOptions: {
      external: [],
    }
  }
}

📚 技术细节

APlayer API 配置

new APlayer({
  container: playerRef.value,  // 容器元素
  audio: musicList,            // 音频列表
  autoplay: false,             // 自动播放
  theme: '#b7daff',           // 主题色
  loop: 'all',                // 循环模式:all/one/none
  order: 'list',              // 播放顺序:list/random
  preload: 'auto',            // 预加载:auto/metadata/none
  volume: 0.7,                // 音量:0-1
  mutex: true,                // 互斥播放
  listFolded: false,          // 列表默认折叠
  listMaxHeight: '250px',     // 列表最大高度
  lrcType: 3,                 // 歌词类型
  storageName: 'aplayer'      // localStorage 键名
});

Meting API 参数

https://api.injahow.cn/meting/?server={server}&type={type}&id={id}
参数说明可选值
server音乐平台netease, tencent, kugou, xiami
type类型song, playlist, album, artist
idID歌曲/歌单/专辑/歌手 ID

返回格式:

[
  {
    "name": "歌曲名",
    "artist": "歌手",
    "url": "音频地址",
    "cover": "封面地址",
    "lrc": "歌词地址"
  }
]

🔗 相关资源

官方文档

在线工具

社区资源

  • GitHub Issues: 遇到问题可以提 Issue
  • VuePress Discord: 加入社区讨论
  • Stack Overflow: 搜索相关问题

📝 更新日志

v1.0.0 (2025-10-31)

  • ✅ 初始版本发布
  • ✅ 实现基础播放功能
  • ✅ 添加拖拽功能
  • ✅ 支持位置记忆
  • ✅ 适配响应式设计
  • ✅ 支持暗黑模式

待实现功能

  • ⏳ 播放器设置面板
  • ⏳ 多歌单快速切换
  • ⏳ 歌曲搜索功能
  • ⏳ 快捷键支持
  • ⏳ 歌词翻译
  • ⏳ 播放历史记录

💡 最佳实践

1. 性能优化

<!-- 使用 v-show 而不是 v-if -->
<div v-show="!isMinimized">...</div>

<!-- 懒加载播放器 -->
<ClientOnly>
  <MusicPlayer />
</ClientOnly>

2. 用户体验

  • 默认不自动播放(避免打扰用户)
  • 记住用户的播放位置和状态
  • 提供清晰的拖拽提示
  • 响应式设计,适配所有设备

3. 错误处理

try {
  await initPlayer();
} catch (error) {
  console.error('初始化失败:', error);
  // 显示友好的错误提示
}

4. 代码组织

  • 组件职责单一
  • 配置与逻辑分离
  • 使用 TypeScript 提供类型安全

🎯 总结

通过本文档,您应该能够:

✅ 在 VuePress 项目中集成音乐播放器
✅ 实现拖拽、状态持久化等高级功能
✅ 自定义播放器样式和配置
✅ 处理常见问题和错误
✅ 进行性能优化和功能扩展

核心要点:

  1. 使用 APlayer + Meting 实现音乐播放
  2. 通过 mousedown/touchstart 实现拖拽
  3. 使用 localStorage 实现状态持久化
  4. 注意响应式设计和浏览器兼容性
  5. 处理好错误情况和边界场景

📞 技术支持

如有问题,欢迎反馈:

  • 项目地址: [您的 Git 仓库]
  • 文档位置: src/notes/other/个人知识库/VuePress/
  • 更新时间: 2025年10月31日

🎵 享受您的编程音乐时光!