PHP项目打包为桌面应用

PHP项目打包为桌面应用,使用PHP Desktop和inno setup实现。

目录

使用工具

[PHP Desktop](#PHP Desktop)

[Inno setup](#Inno setup)

Thinkphp

开始使用

音乐播放器

数据库

创建数据表

导入数据

数据库配置

创建模型层

创建控制器

创建视图

设置路由

准备项目文件

[使用PHP Desktop](#使用PHP Desktop)

[解压PHP Desktop](#解压PHP Desktop)

目录和文件介绍

运行项目

加载项目

放入thinkphp文件

修改配置

运行项目

打包项目

修改配置

创建脚本文件

最终效果

桌面快捷方式

首页

显示播放列表

总结


使用工具

PHP Desktop

PHP Desktop是一个将 PHP 运行环境 + Chromium Embedded Framework (CEF) 打包的工具,能让 PHP 脚本脱离传统 Web 服务器(如 Apache/Nginx),直接以桌面应用形式运行。核心优势是快速将 PHP Web 项目转为跨平台桌面程序(Windows/Linux/macOS)。

根据环境进行下载,我用的是windows。

Inno setup

Inno setup用的是汉化版,原版打包程序时没有中文语言包。

在浏览器中搜索 "inno setup 中文版下载"。

我用的是这个,下载后安装一下。

Thinkphp

下载的thinkphp版本要求6以上,因为php_desktop中的php版本为8。

版本低的话可能加载会有问题。

直接使用命令安装:

bash 复制代码
composer create-project topthink/think tp

开始使用

音乐播放器

使用thinkphp开发一个简单的音乐播放器项目,使用的是mysql数据库,

功能简单介绍:页面是一个音乐播放器,从数据库加载一下音乐记录,然后调用本地的音乐封面和音乐文件进行显示和播放。可以切换上一首、下一首、开始播放、暂停播放、设置音量大小等功能。

数据库

创建一个music.com数据库,字符集采用utf8mb4。

创建数据表
sql 复制代码
CREATE TABLE `music` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `title` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '歌曲名称',
  `singer` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '歌手名称',
  `path` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '音乐文件路径',
  `cover` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '封面图片路径(允许为空,因部分音乐可能无封面)',
  `sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序',
  `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1正常 0禁用',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='音乐表';
导入数据
sql 复制代码
INSERT INTO `music.com`.`music` (`id`, `title`, `singer`, `path`, `cover`, `sort`, `status`, `create_time`, `update_time`) VALUES (1, '雨一直下', '张宇', '/music/yuyizhixia.mp3', '/cover/yuyizhixia.png', 1, 1, '2025-12-06 17:25:13', '2025-12-12 10:38:41');
INSERT INTO `music.com`.`music` (`id`, `title`, `singer`, `path`, `cover`, `sort`, `status`, `create_time`, `update_time`) VALUES (2, '趁早', '张宇', '/music/chenzao.mp3', '/cover/chenzao.png', 2, 1, '2025-12-06 17:25:13', '2025-12-12 10:38:43');
INSERT INTO `music.com`.`music` (`id`, `title`, `singer`, `path`, `cover`, `sort`, `status`, `create_time`, `update_time`) VALUES (3, '一言难尽', '张宇', '/music/yiyannanjin.mp3', '/cover/yiyannanjin.png', 3, 1, '2025-12-06 17:25:13', '2025-12-12 10:38:45');
数据库配置

创建.env文件,设置项目配置,如下:

php 复制代码
APP_DEBUG = true

[APP]
DEFAULT_TIMEZONE = Asia/Shanghai

[DATABASE]
TYPE = mysql
HOSTNAME = 127.0.0.1
DATABASE = music.com
USERNAME = root
PASSWORD = root
HOSTPORT = 3306
CHARSET = utf8
DEBUG = true

[LANG]
default_lang = zh-cn
创建模型层

在app中创建model目录,在model目录下创建Music.php文件,内容如下:

php 复制代码
<?php
namespace app\model;

use think\Model;

class Music extends Model
{
    protected $autoWriteTimestamp = true;

    protected $createTime = 'create_time';

    protected $updateTime = 'update_time';

    protected $dateFormat = 'Y-m-d H:i:s';
}
创建控制器

在app/controller中创建Music.php,

播放器页面显示,播放器列表,播放器信息、同步音乐库、上传音乐功能实现,

内容如下:

php 复制代码
<?php
namespace app\controller;

use think\db\exception\DbException;
use think\exception\ValidateException;
use think\facade\Request;
use think\facade\View;

class Music extends Api
{
    /**
     * 首页(播放器页面)
     */
    public function index()
    {
        return View::fetch('player');
    }

    /**
     * 获取音乐列表
     */
    public function getList()
    {
        $list = \app\model\Music::where('status', 1)
            ->order('sort', 'asc')
            ->field('id, title, singer, path, cover')
            ->select()->toArray();

        return $this->success('success', $list);
    }

    /**
     * 获取单首音乐信息
     */
    public function getInfo()
    {
        $id = Request::param('id', 0, 'intval');
        if (!$id) {
            return $this->error(400, '参数错误');
        }

        $music = \app\model\Music::where('id', $id)
            ->where('status', 1)
            ->field('id, title, singer, path, cover')
            ->find();

        if (!$music) {
            return $this->error(404, '音乐不存在');
        }

        return $this->success('success', $music->toArray());
    }

    /**
     * 同步音乐库(核心接口)
     * @return \think\response\Json
     */
    public function syncMusic()
    {
        try {
            // 定义音乐目录(public/music 绝对路径)
            $musicDir = public_path('music');

            // 扫描目录中的音频文件
            $dirMusicList = scan_music_files($musicDir);
            $dirFilePaths = array_column($dirMusicList, 'path');

            // 获取数据库中的音乐记录
            $dbMusicList = \app\model\Music::where('status', 1)
                ->field('id, path')
                ->select()->toArray();
            $dbFilePaths = array_column($dbMusicList, 'path');

            // 对比数据,计算需要新增/删除的记录
            $addPaths = array_diff($dirFilePaths, $dbFilePaths); // 目录有、数据库无 → 新增
            $delPaths = array_diff($dbFilePaths, $dirFilePaths); // 数据库有、目录无 → 删除

            $addCount = 0;
            $delCount = 0;

            // 执行删除操作
            if (!empty($delPaths)) {
                $delCount = \app\model\Music::whereIn('path', $delPaths)->delete();
            }

            // 执行新增操作
            if (!empty($addPaths)) {
                $addData = [];
                foreach ($dirMusicList as $music) {
                    if (in_array($music['path'], $addPaths)) {
                        $addData[] = [
                            'title'     => $music['title'],
                            'singer'    => $music['singer'],
                            'path'      => $music['path'],
                            'cover'     => $music['cover'],
                            'sort'      => 0,
                            'status'    => 1,
                            'create_time' => date('Y-m-d H:i:s'),
                            'update_time' => date('Y-m-d H:i:s'),
                        ];
                    }
                }
                if (!empty($addData)) {
                    \app\model\Music::insertAll($addData);
                    $addCount = count($addData);
                }
            }

            // 返回同步结果
            $msg = "同步成功!";
            if ($addCount) {
                $msg .= ",新增 {$addCount} 首";
            }
            if ($delCount) {
                $msg .= ",删除 {$delCount} 首";
            }
            return $this->success($msg, [
                'add_count' => $addCount,
                'del_count' => $delCount,
            ]);

        } catch (DbException|\Exception $e) {
            return $this->error(500,'同步失败:' . $e->getMessage());
        }
    }

    /**
     * 上传本地音乐文件
     * @return \think\response\Json
     */
    public function uploadMusic()
    {
        // 强制返回 JSON 格式,关闭 TP6 的 HTML 错误页
        header('Content-Type: application/json; charset=utf-8');

        try {
            // 获取上传文件
            $file = Request::file('music_file');
            if (!$file) {
                return $this->error(400,'请选择要上传的音乐文件');
            }

            // 2. 手动验证文件(替代 validate() 方法,核心修复!)
            $allowedExts = ['mp3', 'wav', 'flac', 'ogg', 'm4a']; // 允许的扩展名
            $maxSize = 20 * 1024 * 1024; // 20MB(字节)

            // 获取文件基础信息(TP6 UploadedFile 支持的方法)
            $fileExt = strtolower($file->getOriginalExtension()); // 原始扩展名(小写)
            $fileSize = $file->getSize(); // 文件大小(字节)
            $fileName = $file->getOriginalName(); // 原始文件名

            // 验证扩展名
            if (!in_array($fileExt, $allowedExts)) {
                return $this->error(400, "文件格式不支持!仅允许:" . implode(',', $allowedExts));
            }

            // 验证文件大小
            if ($fileSize > $maxSize) {
                return $this->error(400, "文件过大!最大支持 20MB,当前文件大小:" . round($fileSize/1024/1024, 2) . "MB");
            }

            // 确保上传目录存在(避免移动文件失败)
            $uploadDir = app()->getRootPath() . 'public/music';
            if (!is_dir($uploadDir)) {
                mkdir($uploadDir, 0755, true); // 递归创建目录
            }

            // 移动文件到 public/music 目录
            // 保留原始文件名(可能覆盖同名文件)
            $savePath = $file->move($uploadDir, $fileName);

            // 组装返回数据(前端可访问的路径)
            $filePath = '/music/' . $savePath->getBasename(); // 如 /music/张宇-雨一直下.mp3

            // 返回上传成功结果
            return $this->success('文件上传成功', [
                'file_name' => $fileName,
                'file_path' => $filePath
            ]);
        } catch (ValidateException|\Exception $e) {
            return $this->error(500,'上传失败:' . $e->getMessage());
        }
    }
}
创建视图

在view下创建music目录,在music目录下创建player.html视图文件,

样式和js代码、html内容都在一起,内容比较多。

内容如下:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>简单音乐播放器 - ThinkPHP 6.1</title>
  <link rel="icon" href="data:;base64,iVBORw0KGgo=">
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: "Microsoft YaHei", sans-serif;
      background: #f5f5f5;
      color: #333;
    }

    .container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 20px;
    }

    .player-box {
      background: #fff;
      border-radius: 10px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
      padding: 30px;
      margin-bottom: 30px;
      display: flex;
      align-items: center;
      gap: 30px;
    }

    .cover-img {
      width: 200px;
      height: 200px;
      border-radius: 8px;
      overflow: hidden;
      box-shadow: 0 0 10px rgba(0,0,0,0.2);
    }

    .cover-img img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    .player-info {
      flex: 1;
    }

    .music-title {
      font-size: 24px;
      font-weight: bold;
      margin-bottom: 10px;
    }

    .music-singer {
      font-size: 16px;
      color: #666;
      margin-bottom: 20px;
    }

    .progress-bar {
      width: 100%;
      height: 6px;
      background: #eee;
      border-radius: 3px;
      margin-bottom: 20px;
      cursor: pointer;
    }

    .progress {
      height: 100%;
      background: #1890ff;
      border-radius: 3px;
      width: 0%;
    }

    .control-buttons {
      display: flex;
      gap: 10px; /* 缩小间距避免拥挤 */
      align-items: center;
    }

    /* 同步/列表图标按钮样式 */
    #sync-icon, #list-icon {
      width: 40px;
      height: 40px;
      font-size: 18px;
      cursor: pointer;
    }

    /* 右侧悬浮列表面板样式 */
    .music-list-panel {
      position: fixed;
      right: 0;
      top: 0;
      width: 300px;
      height: 100vh; /* 占满视口高度 */
      background: #fff;
      box-shadow: -2px 0 10px rgba(0,0,0,0.1);
      padding: 20px;
      z-index: 999; /* 确保在最上层 */
      display: none; /* 默认隐藏 */
      overflow-y: auto; /* 列表过长时滚动 */
    }

    /* 列表项样式(适配面板) */
    .music-item {
      padding: 12px 15px;
      border-radius: 6px;
      margin-bottom: 10px;
      cursor: pointer;
      transition: all 0.3s;
      display: flex;
      align-items: center;
      gap: 15px;
    }
    .music-item:hover {
      background: #f0f7ff;
    }
    .music-item.active {
      background: #e6f7ff;
      border-left: 4px solid #1890ff;
    }

    .btn {
      width: 50px;
      height: 50px;
      border-radius: 50%;
      background: #1890ff;
      color: #fff;
      border: none;
      font-size: 20px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.3s;
    }

    .btn:hover {
      background: #096dd9;
      transform: scale(1.05);
    }

    .btn-play {
      width: 60px;
      height: 60px;
      font-size: 24px;
    }

    .music-list {
      background: #fff;
      border-radius: 10px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
      padding: 20px;
    }

    .list-title {
      font-size: 18px;
      font-weight: bold;
      margin-bottom: 20px;
      padding-bottom: 10px;
      border-bottom: 1px solid #eee;
    }

    .music-item {
      padding: 12px 15px;
      border-radius: 6px;
      margin-bottom: 10px;
      cursor: pointer;
      transition: all 0.3s;
      display: flex;
      align-items: center;
      gap: 15px;
    }

    .music-item:hover {
      background: #f0f7ff;
    }

    .music-item.active {
      background: #e6f7ff;
      border-left: 4px solid #1890ff;
    }

    .item-cover {
      width: 40px;
      height: 40px;
      border-radius: 4px;
      overflow: hidden;
    }

    .item-cover img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    .item-info {
      flex: 1;
    }

    .item-title {
      font-weight: 500;
      margin-bottom: 3px;
    }

    .item-singer {
      font-size: 12px;
      color: #666;
    }

    /* 音量控制样式 */
    .volume-control {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-left: 15px; /* 缩小左侧间距 */
    }

    .volume-icon {
      font-size: 18px;
      color: #666;
      cursor: pointer;
      transition: color 0.3s;
    }

    .volume-icon:hover {
      color: #1890ff;
    }

    .volume-slider {
      width: 80px;
      height: 4px;
      -webkit-appearance: none;
      appearance: none;
      background: #eee;
      border-radius: 2px;
      outline: none;
      cursor: pointer;
    }

    .volume-slider::-webkit-slider-thumb {
      -webkit-appearance: none;
      appearance: none;
      width: 12px;
      height: 12px;
      border-radius: 50%;
      background: #1890ff;
      transition: all 0.3s;
    }

    .volume-slider::-webkit-slider-thumb:hover {
      background: #096dd9;
      transform: scale(1.2);
    }

    .volume-percent {
      font-size: 12px;
      color: #666;
      width: 30px;
      text-align: center;
    }

    /* 提示消息样式 */
    .toast {
      position: fixed;
      top: 20px;
      right: 20px;
      padding: 12px 20px;
      border-radius: 6px;
      color: #fff;
      font-size: 14px;
      z-index: 9999;
      opacity: 0;
      transition: opacity 0.3s;
    }

    .toast.success {
      background: #52c41a;
    }

    .toast.error {
      background: #f5222d;
    }

    .toast.show {
      opacity: 1;
    }

    /* 上传组件样式 */
    .upload-box {
      background: #f9f9f9;
      border: 2px dashed #d9d9d9;
      border-radius: 8px;
      padding: 30px;
      text-align: center;
      margin-bottom: 20px;
      cursor: pointer;
      transition: all 0.3s;
    }

    .upload-box:hover {
      border-color: #1890ff;
      background: #f0f7ff;
    }

    .upload-box.active {
      border-color: #52c41a;
      background: #f6ffed;
    }

    .upload-icon {
      font-size: 40px;
      color: #ccc;
      margin-bottom: 10px;
    }

    .upload-text {
      font-size: 16px;
      color: #666;
      margin-bottom: 5px;
    }

    .upload-tip {
      font-size: 12px;
      color: #999;
    }

    #upload-file {
      display: none; /* 隐藏原生文件选择框 */
    }

    .upload-progress {
      height: 6px;
      background: #eee;
      border-radius: 3px;
      margin-top: 15px;
      overflow: hidden;
      display: none;
    }

    .upload-progress-bar {
      height: 100%;
      background: #1890ff;
      width: 0%;
      transition: width 0.3s;
    }

    /* 调整同步按钮位置,和上传组件对齐 */
    .operation-bar {
      display: flex;
      gap: 15px;
      align-items: center;
      margin-bottom: 15px;
    }

    /* 同步图标禁用状态样式 */
    #sync-icon:disabled {
      cursor: not-allowed; /* 鼠标变为禁止样式 */
      opacity: 0.6; /* 透明度降低,提示不可点击 */
      background: #ccc; /* 背景变灰 */
    }

    /* 同步中加载图标样式(可选) */
    #sync-icon.loading {
      animation: spin 1s linear infinite; /* 旋转动画 */
    }

    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
  </style>
</head>
<body>
<div class="container">
  <!-- 播放器主体 -->
  <div class="player-box">
    <div class="cover-img">
      <img id="current-cover" src="/cover/default.png" alt="封面">
    </div>
    <div class="player-info">
      <div class="music-title" id="current-title">请选择歌曲</div>
      <div class="music-singer" id="current-singer">--</div>
      <div class="progress-bar" id="progress-bar">
        <div class="progress" id="progress"></div>
      </div>
      <div class="control-buttons">
        <button class="btn" id="prev-btn">◀</button>
        <button class="btn btn-play" id="play-btn">▶</button>
        <button class="btn" id="next-btn">▶</button>

        <!-- 新增:同步图标(替换原同步按钮) -->
        <button class="btn" id="sync-icon" title="同步音乐库">🔄</button>
        <!-- 新增:列表图标(控制列表显示) -->
        <button class="btn" id="list-icon" title="显示音乐列表">📜</button>

        <!-- 音量控制模块(调整间距避免拥挤) -->
        <div class="volume-control">
          <span class="volume-icon" id="volume-icon">🔊</span>
          <input type="range" class="volume-slider" id="volume-slider" min="0" max="1" step="0.01" value="1">
          <span class="volume-percent" id="volume-percent">100%</span>
        </div>
      </div>
    </div>
  </div>

  <!-- 上传组件(保留) -->
  <div class="upload-box" id="upload-box">
    <input type="file" id="upload-file" accept=".mp3,.wav,.flac,.ogg,.m4a" multiple>
    <div class="upload-icon">📁</div>
    <div class="upload-text">点击或拖拽文件到此处上传</div>
    <div class="upload-tip">支持格式:MP3、WAV、FLAC、OGG、M4A(单文件最大20MB)</div>
    <div class="upload-progress" id="upload-progress">
      <div class="upload-progress-bar" id="upload-progress-bar"></div>
    </div>
  </div>

  <!-- 右侧悬浮音乐列表面板(默认隐藏) -->
  <div class="music-list-panel" id="music-list-panel">
    <div class="list-title">音乐列表</div>
    <div id="music-list-container"></div>
  </div>

  <!-- 提示消息容器 -->
  <div class="toast" id="toast"></div>
</div>

<!-- 音频元素(隐藏) -->
<audio id="audio-player" preload="auto">
  <source id="audio-source" src="" type="audio/mpeg">
  您的浏览器不支持音频播放
</audio>

<script>
  // 全局变量
  let musicList = [];  // 音乐列表
  let currentIndex = -1;  // 当前播放索引
  let isPlaying = false;  // 播放状态

  let currentVolume = 1;  // 当前音量(0-1,默认最大音量)
  let isMuted = false;    // 是否静音

  // DOM元素
  const audioPlayer = document.getElementById('audio-player');
  const audioSource = document.getElementById('audio-source');
  const playBtn = document.getElementById('play-btn');
  const prevBtn = document.getElementById('prev-btn');
  const nextBtn = document.getElementById('next-btn');
  const progressBar = document.getElementById('progress-bar');
  const progress = document.getElementById('progress');
  const currentTitle = document.getElementById('current-title');
  const currentSinger = document.getElementById('current-singer');
  const currentCover = document.getElementById('current-cover');
  const musicListContainer = document.getElementById('music-list-container');

  // 同步相关dom
  const toast = document.getElementById('toast');

  // 音量相关dom
  const volumeIcon = document.getElementById('volume-icon');
  const volumeSlider = document.getElementById('volume-slider');
  const volumePercent = document.getElementById('volume-percent');

  // 上传相关元素
  const uploadBox = document.getElementById('upload-box');
  const uploadFile = document.getElementById('upload-file');
  const uploadProgress = document.getElementById('upload-progress');
  const uploadProgressBar = document.getElementById('upload-progress-bar');

  // 新增DOM元素(列表/同步图标、悬浮面板)
  const syncIcon = document.getElementById('sync-icon');
  const listIcon = document.getElementById('list-icon');
  const musicListPanel = document.getElementById('music-list-panel');

  // 初始化:加载音乐列表
  window.onload = async function() {
    await loadMusicList();
    bindEvents();
    renderMusicList(); // 预渲染列表(点击图标时直接显示)
  };

  // 加载音乐列表
  async function loadMusicList() {
    try {
      const response = await fetch('index.php/music/getList');
      const res = await response.json();
      if (res.code === 200) {
        musicList = res.data;
        renderMusicList();
      } else {
        alert('加载音乐列表失败:' + res.msg);
      }
    } catch (error) {
      console.error('加载音乐列表出错:', error);
      alert('加载音乐列表出错,请刷新重试');
    }
  }

  // 渲染音乐列表
  function renderMusicList() {
    musicListContainer.innerHTML = '';
    musicList.forEach((music, index) => {
      const item = document.createElement('div');
      item.className = `music-item ${index === currentIndex ? 'active' : ''}`;
      item.dataset.index = index;
      item.innerHTML = `
                    <div class="item-cover">
                        <img src="${music.cover || '/cover/default.png'}" alt="${music.title}">
                    </div>
                    <div class="item-info">
                        <div class="item-title">${music.title}</div>
                        <div class="item-singer">${music.singer}</div>
                    </div>
                `;
      musicListContainer.appendChild(item);
    });
  }

  // 绑定事件
  function bindEvents() {
    // 播放/暂停按钮
    playBtn.addEventListener('click', togglePlay);

    // 上一首
    prevBtn.addEventListener('click', playPrev);

    // 下一首
    nextBtn.addEventListener('click', playNext);

    // 进度条点击
    progressBar.addEventListener('click', seek);

    // 音频时间更新
    audioPlayer.addEventListener('timeupdate', updateProgress);

    // 音频播放结束
    audioPlayer.addEventListener('ended', playNext);

    // 播放列表
    // 1. 同步图标点击事件(替换原同步按钮)
    syncIcon.addEventListener('click', syncMusicLibrary);

    // 2. 列表图标点击:显示右侧悬浮列表
    listIcon.addEventListener('click', () => {
      musicListPanel.style.display = 'block';
      renderMusicList(); // 确保列表是最新数据
    });

    // 3. 列表面板鼠标离开:隐藏列表
    musicListPanel.addEventListener('mouseleave', () => {
      musicListPanel.style.display = 'none';
    });

    // 4. 列表项点击播放(原有逻辑保留,适配新面板)
    musicListContainer.addEventListener('click', function(e) {
      const item = e.target.closest('.music-item');
      if (item) {
        const index = parseInt(item.dataset.index);
        playMusic(index);
        // 鼠标离开面板后自动隐藏,无需手动关闭
      }
    });

    // 新增:音量滑块事件
    volumeSlider.addEventListener('input', adjustVolume);

    // 新增:静音图标点击事件
    volumeIcon.addEventListener('click', toggleMute);

    // 新增:音频音量变化事件(同步滑块和图标)
    audioPlayer.addEventListener('volumechange', syncVolumeUI);

    // 新增:上传框点击触发文件选择
    uploadBox.addEventListener('click', () => {
      uploadFile.click();
    });

    // 新增:文件选择后触发上传
    uploadFile.addEventListener('change', (e) => {
      const files = e.target.files;
      if (files.length > 0) {
        uploadFiles(files);
      }
    });

    // 新增:拖拽上传(可选,增强体验)
    uploadBox.addEventListener('dragover', (e) => {
      e.preventDefault();
      uploadBox.classList.add('active');
    });

    uploadBox.addEventListener('dragleave', () => {
      uploadBox.classList.remove('active');
    });

    uploadBox.addEventListener('drop', (e) => {
      e.preventDefault();
      uploadBox.classList.remove('active');
      const files = e.dataTransfer.files;
      if (files.length > 0) {
        uploadFiles(files);
      }
    });
  }

  // 播放/暂停切换
  function togglePlay() {
    if (currentIndex === -1 && musicList.length > 0) {
      // 还没选择歌曲,默认播放第一首
      playMusic(0);
      return;
    }

    if (isPlaying) {
      audioPlayer.pause();
      playBtn.innerHTML = '▶';
    } else {
      audioPlayer.play();
      playBtn.innerHTML = '❚❚';
    }
    isPlaying = !isPlaying;
  }

  // 播放指定索引的音乐
  function playMusic(index) {
    if (index < 0 || index >= musicList.length) return;

    currentIndex = index;
    const music = musicList[index];

    // 更新播放器信息
    currentTitle.textContent = music.title;
    currentSinger.textContent = music.singer;
    currentCover.src = music.cover || '/cover/default.png';
    audioSource.src = music.path;
    audioPlayer.load();
    audioPlayer.play();

    // 新增:同步音量设置(切换歌曲时保持当前音量和静音状态)
    audioPlayer.volume = currentVolume;
    audioPlayer.muted = isMuted;
    syncVolumeUI();  // 同步 UI

    // 更新状态
    isPlaying = true;
    playBtn.innerHTML = '❚❚';

    // 更新列表选中状态
    renderMusicList();
  }

  // 播放上一首
  function playPrev() {
    if (musicList.length === 0) return;
    let index = currentIndex - 1;
    if (index < 0) index = musicList.length - 1;
    playMusic(index);
  }

  // 播放下一首
  function playNext() {
    if (musicList.length === 0) return;
    let index = currentIndex + 1;
    if (index >= musicList.length) index = 0;
    playMusic(index);
  }

  // 更新进度条
  function updateProgress() {
    const duration = audioPlayer.duration;
    const currentTime = audioPlayer.currentTime;
    const percent = (currentTime / duration) * 100;
    progress.style.width = `${percent}%`;
  }

  // 进度条跳转
  function seek(e) {
    const rect = progressBar.getBoundingClientRect();
    const pos = (e.clientX - rect.left) / rect.width;
    const duration = audioPlayer.duration;
    audioPlayer.currentTime = pos * duration;
    progress.style.width = `${pos * 100}%`;
  }

  /**
   * 调整音量(滑块拖动时触发)
   */
  function adjustVolume() {
    currentVolume = parseFloat(volumeSlider.value);
    audioPlayer.volume = currentVolume;
    audioPlayer.muted = false;  // 调整音量时自动取消静音
    isMuted = false;
    syncVolumeUI();  // 同步 UI 显示
  }

  /**
   * 切换静音/取消静音
   */
  function toggleMute() {
    isMuted = !isMuted;
    audioPlayer.muted = isMuted;
    syncVolumeUI();  // 同步 UI 显示
  }

  /**
   * 同步音量 UI 显示(图标、滑块、百分比)
   */
  function syncVolumeUI() {
    // 同步滑块值(如果是手动静音,滑块保持原音量)
    if (!isMuted) {
      volumeSlider.value = audioPlayer.volume;
      currentVolume = audioPlayer.volume;
    }

    // 计算音量百分比
    const percent = Math.round(currentVolume * 100);
    volumePercent.textContent = `${percent}%`;

    // 切换音量图标
    if (isMuted || percent === 0) {
      volumeIcon.textContent = '🔇';  // 静音图标
    } else if (percent < 30) {
      volumeIcon.textContent = '🔈';  // 低音量图标
    } else {
      volumeIcon.textContent = '🔊';  // 正常音量图标
    }
  }

  /**
   * 显示提示消息
   * @param {string} msg 消息内容
   * @param {boolean} isSuccess 是否成功
   */
  function showToast(msg, isSuccess = true) {
    toast.textContent = msg;
    toast.className = `toast ${isSuccess ? 'success' : 'error'} show`;

    // 3秒后隐藏提示
    setTimeout(() => {
      toast.className = 'toast';
    }, 3000);
  }

  /**
   * 同步音乐库核心逻辑
   */
  async function syncMusicLibrary() {
    // 1. 同步开始:禁用同步图标,修改样式/图标
    if (syncIcon) {
      syncIcon.disabled = true; // 禁用按钮,无法点击
      syncIcon.classList.add('loading'); // 添加旋转动画
      syncIcon.textContent = '⟳'; // 替换为加载中图标(可选)
    }

    try {
      // 原有同步逻辑(调用接口、提示消息等)保持不变
      const response = await fetch('/index.php?s=music/sync');
      const res = await response.json();

      if (res.code === 200) {
        showToast(res.msg);
        await loadMusicList();
        renderMusicList();

        // 处理播放状态(原有逻辑)
        if (currentIndex >= 0 && !musicList[currentIndex]) {
          currentIndex = -1;
          isPlaying = false;
          playBtn.innerHTML = '▶';
          currentTitle.textContent = '请选择歌曲';
          currentSinger.textContent = '--';
          currentCover.src = '/cover/default.jpg';
          audioSource.src = '';
          audioPlayer.load();
        }
      } else {
        showToast(res.msg, false);
      }
    } catch (error) {
      showToast('同步失败:网络错误或接口不可用', false);
      console.error('同步音乐库出错:', error);
    } finally {
      // 2. 同步结束:恢复按钮状态(无论成功/失败都执行)
      if (syncIcon) {
        syncIcon.disabled = false; // 启用按钮
        syncIcon.classList.remove('loading'); // 移除旋转动画
        syncIcon.textContent = '🔄'; // 还原原有图标
      }
    }
  }

  /**
   * 上传文件核心逻辑
   * @param {FileList} files 选中的文件列表
   */
  async function uploadFiles(files) {
    // 过滤非音频文件
    const allowedExts = ['.mp3', '.wav', '.flac', '.ogg', '.m4a'];
    const audioFiles = Array.from(files).filter(file => {
      const ext = '.' + file.name.split('.').pop().toLowerCase();
      return allowedExts.includes(ext);
    });

    if (audioFiles.length === 0) {
      showToast('请选择支持的音频格式文件', false);
      return;
    }

    // 显示上传进度条
    uploadProgress.style.display = 'block';
    uploadProgressBar.style.width = '0%';

    let successCount = 0;
    let failCount = 0;

    // 批量上传文件(逐个上传)
    for (let i = 0; i < audioFiles.length; i++) {
      const file = audioFiles[i];
      try {
        // 构建 FormData
        const formData = new FormData();
        formData.append('music_file', file);

        // 发送上传请求(兼容模式路径,适配 php_desktop)
        const response = await fetch('/index.php?s=music/upload', {
          method: 'POST',
          headers: {
            'Accept': 'application/json; charset=utf-8',
          },
          body: formData,
          // 监听上传进度
          onUploadProgress: (e) => {
            if (e.lengthComputable) {
              // 计算总进度(当前文件进度 / 文件总数 + 已完成文件数 / 文件总数)
              const fileProgress = e.loaded / e.total;
              const totalProgress = (i + fileProgress) / audioFiles.length;
              uploadProgressBar.style.width = `${totalProgress * 100}%`;
            }
          }
        });

        const res = await response.json();
        if (res.code === 200) {
          successCount++;
        } else {
          failCount++;
          console.error(`文件 ${file.name} 上传失败:`, res.msg);
        }
      } catch (error) {
        failCount++;
        console.error(`文件 ${file.name} 上传失败:`, error);
      }
    }

    // 上传完成:更新进度条 + 提示结果 + 自动同步音乐库
    uploadProgressBar.style.width = '100%';
    setTimeout(() => {
      uploadProgress.style.display = 'none';
      uploadProgressBar.style.width = '0%';
    }, 500);

    // 显示上传结果提示
    if (successCount > 0 && failCount === 0) {
      showToast(`上传成功!共上传 ${successCount} 首音乐`);
    } else if (successCount > 0 && failCount > 0) {
      showToast(`部分上传成功:成功 ${successCount} 首,失败 ${failCount} 首`, false);
    } else {
      showToast(`上传失败!共 ${failCount} 首文件上传失败`, false);
    }

    // 自动同步音乐库(更新数据库和列表)
    if (successCount > 0) {
      await syncMusicLibrary();
    }

    // 清空文件选择框(允许重复选择同一文件)
    uploadFile.value = '';
  }

</script>
</body>
</html>
设置路由

在route/app.php中设置功能路由,如下:

php 复制代码
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
use think\facade\Route;

// 播放器首页
Route::get('/', 'Music/index');
Route::get('/player', 'Music/index');

// 接口路由
Route::get('/music/getList', 'Music/getList');
Route::get('/music/getInfo', 'Music/getInfo');
// 音乐库同步接口
Route::get('/music/sync', 'Music/syncMusic');
// 音乐上传接口
Route::post('/music/upload', 'Music/uploadMusic');
准备项目文件

需要准备音乐封面、音频文件、项目icon文件。

创建cover和music目录,存储封面和音乐文件,favicon.ico为项目icon文件。

使用PH P Desktop

解压PHP Desktop

把下载的php desktop压缩包在本地解压,内容如下:

目录和文件介绍

locales: 内置 Chrome/CEF(Chromium Embedded Framework)内核的本地化(多语言)资源目录,负责支撑内核层面的语言适配,和 PHP/ThinkPHP/FastAdmin 的业务级语言配置完全无关。

php:是php运行程序和扩展程序

www:可以放置php项目文件,或者php项目入口文件。

settings.json php desktop项目配置文件,在这里修改配置

运行项目

直接双击使用phpdesktop-chrome.exe运行项目,会启动php环境运行demo项目,

在www目录下有一些php demo文件。

效果如下:

加载项目
放入thinkphp文件

把thinkphp项目放入php desktop项目中,然后修改配置,并正常运行起项目。

操作步骤如下:

把www目录删除,然后把thinkphp中tp下的所有文件和目录复制放到www同级中。

如下:

修改配置

把www目录改为public,并设置项目信息:项目入口文件、项目icon文件、项目名称等。

如下:

运行项目

双击phpdesktop-chrome.exe运行项目,效果如下:

打包项目

修改配置

打包前修改项目配置,关闭调试如下:

关闭thinkphp debug

创建脚本文件

安装Inno setup汉化程序后,创建脚本文件。

步骤如下:

下一步 填写应用程序信息

设置应用程序文件夹

添加项目可执行文件,把整个项目通过添加文件夹加入。

之后一直下一步,然后设定安装icon

选择不编辑

然后修改脚本,增加icon设置

开始编译

选择构建->编译

保存脚本

开始编译

编译完成后,在phpdesktop中的Output目录下生成exe文件,如下:

最终效果

桌面快捷方式

双击后正常安装,安装完成后如下:

首页

播放音乐并可点击或者拖拽文件上传

显示播放列表

点击列表图标后 从右侧显示播放列表。

总结

使用PHP Desktop和inno setup确实可以把php项目打包为桌面应用,安装、使用和卸载与其他桌面应用并无差异,但是首先安装应用过大,而且无法自定义软件图标,软件源码也不能加密。

相关推荐
2301_822366351 小时前
C++中的协程编程
开发语言·c++·算法
m0_736919101 小时前
C++中的事件驱动编程
开发语言·c++·算法
上海合宙LuatOS1 小时前
LuatOS框架的使用(1)
java·开发语言·单片机·嵌入式硬件·物联网·ios·iphone
lxl13071 小时前
学习C++(4)构造函数+析构函数+拷贝构造函数
开发语言·c++·学习
阿kun要赚马内2 小时前
Qt写群聊项目(二):客户端
开发语言·c++·qt
轩情吖2 小时前
数据结构-并查集
开发语言·数据结构·c++·后端··并查集
wjs20242 小时前
SQL CREATE DATABASE 命令详解
开发语言
独自破碎E2 小时前
LCR001-两数相除
java·开发语言
70asunflower2 小时前
Python网络内容下载框架教程
开发语言·网络·python