基于 Laravel 5.7 开发博客应用系列(五) —— 在后台实现文件上传删除管理功能

本节我们将在后台为博客应用实现文件上传管理(包括文件上传、预览及删除、目录创建及删除)功能,并且使用本地文件系统保存上传的文件。

1、配置本地文件系统

让我们从配置开始吧。在文件存储系统中,Laravel 为我们提供一个公共磁盘用于存放可以通过 Web 公开访问的资源,这些资源默认存放在 storage/app/public 目录下,要通过 Web 访问该目录,可以用系统内置的 Artisan 命令创建一个软链接来实现:

php artisan storage:link

该命令会在根目录下的 public 目录中创建一个软链 storage 指向 storage/app/public 目录,这样,就完成了 Web 访问路径配置。

接下来我们来编辑 config/blog.php

<?php

return [
    'title' => 'My Blog',
    'posts_per_page' => 5,
    'uploads' => [
        'storage' => 'public',
        'webpath' => '/storage',
    ],
];

我们在 uploads 配置项中使用 storage 配置使用的文件系统,使用 webpath 配置 Web 访问根目录。

2、创建辅助函数文件

在 Laravel 项目中有时我们会需要一些不依赖于类的辅助函数,通常我们会将这些辅助函数定义在一个单独文件如 helpers.php 中。我们在 app 目录下创建这个名为 helpers.php 的文件,并编辑其内容如下:

<?php

/**
 * 返回可读性更好的文件尺寸
 */
function human_filesize($bytes, $decimals = 2)
{
    $size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
    $factor = floor((strlen($bytes) - 1) / 3);

    return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) .@$size[$factor];
}

/**
 * 判断文件的MIME类型是否为图片
 */
function is_image($mimeType)
{
    return starts_with($mimeType, 'image/');
}

其中 human_filesize() 函数返回一个易读的文件尺寸,is_image() 函数在文件类型为图片的时候返回 true

要让应用能够正确找到 helpers.php 文件,还要修改项目根目录下 composer.jsonautoload 配置:

"autoload": {
    "psr-4": {
        "App\\": "app/"
    },
    "classmap": [
        "database/seeds",
        "database/factories"
    ],
    "files": [
        "app/helpers.php"
    ]
},

autoload 配置项的 files 数组中指定要被加载的文件/文件夹。修改完成后记得运行 composer dumpauto 确保让修改生效:

composer dumpauto

现在 helpers.php 中的所有函数都会载入到自动加载器中,你可以在博客应用的代码中任意使用其中的函数。

3、创建文件上传管理服务

现在基本配置已经完成了,让我们创建一个服务类来管理上传文件。

检测文件 MIME 类型

我们想要基于不同类型的上传文件进行不同的操作,这可以通过检测上传文件 MIME 类型轻松实现。

PHP 有一个内置函数 mime_content_type() 用于检测文件的 MIME 类型,但是该函数已经废弃了,我们使用另一个解决方案。

Packagist 中搜索「mime」会查询到一个名为 dflydev/apache-mime-types 的包,我们在博客项目中使用 Composer 安装该依赖包:

composer require "dflydev/apache-mime-types"

我们将使用该依赖包提供的方法来检测文件的 MIME 类型。

创建UploadsManager类

app/Services 目录下创建 UploadsManager.php,编辑其内容如下:

<?php

namespace App\Services;

use Carbon\Carbon;
use Illuminate\Support\Facades\Storage;
use Dflydev\ApacheMimeTypes\PhpRepository;

class UploadsManager
{
    protected $disk;
    protected $mimeDetect;

    public function __construct(PhpRepository $mimeDetect)
    {
        $this->disk = Storage::disk(config('blog.uploads.storage'));
        $this->mimeDetect = $mimeDetect;
    }

    /**
     * Return files and directories within a folder
     *
     * @param string $folder
     * @return array of [
     *     'folder' => 'path to current folder',
     *     'folderName' => 'name of just current folder',
     *     'breadCrumbs' => breadcrumb array of [ $path => $foldername ]
     *     'folders' => array of [ $path => $foldername] of each subfolder
     *     'files' => array of file details on each file in folder
     * ]
     */
    public function folderInfo($folder)
    {
        $folder = $this->cleanFolder($folder);

        $breadcrumbs = $this->breadcrumbs($folder);
        $slice = array_slice($breadcrumbs, -1);
        $folderName = current($slice);
        $breadcrumbs = array_slice($breadcrumbs, 0, -1);

        $subfolders = [];
        foreach (array_unique($this->disk->directories($folder)) as $subfolder) {
            $subfolders["/$subfolder"] = basename($subfolder);
        }

        $files = [];
        foreach ($this->disk->files($folder) as $path) {
            $files[] = $this->fileDetails($path);
        }

        return compact(
            'folder',
            'folderName',
            'breadcrumbs',
            'subfolders',
            'files'
        );
    }

    /**
     * Sanitize the folder name
     */
    protected function cleanFolder($folder)
    {
        return '/' . trim(str_replace('..', '', $folder), '/');
    }

    /**
     * 返回当前目录路径
     */
    protected function breadcrumbs($folder)
    {
        $folder = trim($folder, '/');
        $crumbs = ['/' => 'root'];

        if (empty($folder)) {
            return $crumbs;
        }

        $folders = explode('/', $folder);
        $build = '';
        foreach ($folders as $folder) {
            $build .= '/' . $folder;
            $crumbs[$build] = $folder;
        }

        return $crumbs;
    }

    /**
     * 返回文件详细信息数组
     */
    protected function fileDetails($path)
    {
        $path = '/' . ltrim($path, '/');

        return [
            'name' => basename($path),
            'fullPath' => $path,
            'webPath' => $this->fileWebpath($path),
            'mimeType' => $this->fileMimeType($path),
            'size' => $this->fileSize($path),
            'modified' => $this->fileModified($path),
        ];
    }

    /**
     * 返回文件完整的web路径
     */
    public function fileWebpath($path)
    {
        $path = rtrim(config('blog.uploads.webpath'), '/') . '/' . ltrim($path, '/');
        return url($path);
    }

    /**
     * 返回文件MIME类型
     */
    public function fileMimeType($path)
    {
        return $this->mimeDetect->findType(
            pathinfo($path, PATHINFO_EXTENSION)
        );
    }

    /**
     * 返回文件大小
     */
    public function fileSize($path)
    {
        return $this->disk->size($path);
    }

    /**
     * 返回最后修改时间
     */
    public function fileModified($path)
    {
        return Carbon::createFromTimestamp(
            $this->disk->lastModified($path)
        );
    }
}

4、实现文件上传管理列表

现在 UploadsManager 服务类已经创建,接下来我们来实现控制器的 index 方法。

创建 index 方法

编辑 app/Http/Controllers/Admin 目录下的 UploadController.php 文件内容如下:

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Services\UploadsManager;
use Illuminate\Http\Request;

class UploadController extends Controller
{
    protected $manager;

    public function __construct(UploadsManager $manager)
    {
        $this->manager = $manager;
    }

    /**
     * Show page of files / subfolders
     */
    public function index(Request $request)
    {
        $folder = $request->get('folder');
        $data = $this->manager->folderInfo($folder);

        return view('admin.upload.index', $data);
    }
}

构造方法中注入了 UploadsManager 依赖,在 index() 方法中只需传入 folderInfo() 返回的数据到要渲染的视图并返回即可。

你可能已经注意到 $folder 从请求中获取,是的,我们只需要通过请求参数即可实现文件夹修改。

创建 index 视图

resources/views/admin 目录下新建 upload 目录,并在该目录下创建 index.blade.php 文件,编辑该文件内容如下:

@extends('admin.layout')

@section('content')
    <div class="container">

        {{-- 顶部工具栏 --}}
        <div class="row page-title-row">
            <div class="col-md-6">
                <h3 class="pull-left">上传 </h3>
                <div class="pull-left">
                    <ul class="breadcrumb">
                        @foreach ($breadcrumbs as $path => $disp)
                            <li><a href="/admin/upload?folder={{ $path }}">{{ $disp }}</a></li>
                        @endforeach
                        <li class="active">{{ $folderName }}</li>
                    </ul>
                </div>
            </div>
            <div class="col-md-6 text-right">
                <button type="button" class="btn btn-success btn-md" data-toggle="modal" data-target="#modal-folder-create">
                    <i class="fa fa-plus-circle"></i> 新增目录
                </button>
                <button type="button" class="btn btn-primary btn-md" data-toggle="modal" data-target="#modal-file-upload">
                    <i class="fa fa-upload"></i> 上传
                </button>
            </div>
        </div>

        <div class="row">
            <div class="col-sm-12">

                @include('admin.partials.errors')
                @include('admin.partials.success')

                <table id="uploads-table" class="table table-striped table-bordered">
                    <thead>
                    <tr>
                        <th>名称</th>
                        <th>类型</th>
                        <th>日期</th>
                        <th>尺寸</th>
                        <th data-sortable="false">操作</th>
                    </tr>
                    </thead>
                    <tbody>

                    {{-- 子目录 --}}
                    @foreach ($subfolders as $path => $name)
                        <tr>
                            <td>
                                <a href="/admin/upload?folder={{ $path }}">
                                    <i class="fa fa-folder fa-lg fa-fw"></i>
                                    {{ $name }}
                                </a>
                            </td>
                            <td>目录</td>
                            <td>-</td>
                            <td>-</td>
                            <td>
                                <button type="button" class="btn btn-xs btn-danger" onclick="delete_folder('{{ $name }}')">
                                    <i class="fa fa-times-circle fa-lg"></i>
                                    删除
                                </button>
                            </td>
                        </tr>
                    @endforeach

                    {{-- 所有文件 --}}
                    @foreach ($files as $file)
                        <tr>
                            <td>
                                <a href="{{ $file['webPath'] }}">
                                    @if (is_image($file['mimeType']))
                                        <i class="fa fa-image fa-lg fa-fw"></i>
                                    @else
                                        <i class="fa fa-file fa-lg fa-fw"></i>
                                    @endif
                                    {{ $file['name'] }}
                                </a>
                            </td>
                            <td>{{ $file['mimeType'] ? : 'Unknown' }}</td>
                            <td>{{ $file['modified']->format('j-M-y g:ia') }}</td>
                            <td>{{ human_filesize($file['size']) }}</td>
                            <td>
                                <button type="button" class="btn btn-xs btn-danger" onclick="delete_file('{{ $file['name'] }}')">
                                    <i class="fa fa-times-circle fa-lg"></i>
                                    删除
                                </button>
                                @if (is_image($file['mimeType']))
                                    <button type="button" class="btn btn-xs btn-success" onclick="preview_image('{{ $file['webPath'] }}')">
                                        <i class="fa fa-eye fa-lg"></i>
                                        预览
                                    </button>
                                @endif
                            </td>
                        </tr>
                    @endforeach

                    </tbody>
                </table>

            </div>
        </div>
    </div>

    @include('admin.upload._modals')

@stop

@section('scripts')
    <script>

        // 确认文件删除
        function delete_file(name) {
            $("#delete-file-name1").html(name);
            $("#delete-file-name2").val(name);
            $("#modal-file-delete").modal("show");
        }

        // 确认目录删除
        function delete_folder(name) {
            $("#delete-folder-name1").html(name);
            $("#delete-folder-name2").val(name);
            $("#modal-folder-delete").modal("show");
        }

        // 预览图片
        function preview_image(path) {
            $("#preview-image").attr("src", path);
            $("#modal-image-view").modal("show");
        }

        // 初始化数据
        $(function() {
            $("#uploads-table").DataTable();
        });
    </script>
@stop

尽管这个模板文件很长,但是理解起来并没有什么困难,所有文件上传和下载管理都将在这里进行。

有没有注意到我们在最后包含了 admin.upload._modals 子视图?是的,我们将模态对话框放到了一个单独的视图模板中。现在,我们在 resources/views/admin/upload 目录下创建一个空的 _modals.blade.php 文件。

上传管理界面

打开浏览器,进入博客应用后台管理页面,点击顶部导航条的「上传」链接,将会跳转到如下页面:

既漂亮又清爽,有木有?接下来让我们来实现所有的模态对话框及其背后的业务逻辑。

5、完成文件上传管理功能

对于完整的文件上传管理器而言剩下的工作已经不多了,现在是时候完成所有功能了。

添加路由

我们需要为上传管理器定义所有需要的路由,编辑 routes/web.php 添加如下路由:

// 在这一行下面
Route::get('admin/upload', 'UploadController@index');

// 添加如下路由
Route::post('admin/upload/file', 'UploadController@uploadFile');
Route::delete('admin/upload/file', 'UploadController@deleteFile');
Route::post('admin/upload/folder', 'UploadController@createFolder');
Route::delete('admin/upload/folder', 'UploadController@deleteFolder');

定义所有模态对话框

编辑我们之前创建的 _modals.blade.php 文件内容如下:

{{-- 创建目录 --}}
<div class="modal fade" id="modal-folder-create">
    <div class="modal-dialog">
        <div class="modal-content">
            <form method="POST" action="/admin/upload/folder" class="form-horizontal">
                <input type="hidden" name="_token" value="{{ csrf_token() }}">
                <input type="hidden" name="folder" value="{{ $folder }}">
                <div class="modal-header">
                    <h4 class="modal-title">创建新目录</h4>
                    <button type="button" class="close" data-dismiss="modal">
                        ×
                    </button>
                </div>
                <div class="modal-body">
                    <div class="form-group row">
                        <label for="new_folder_name" class="col-sm-3 col-form-label">
                            目录名
                        </label>
                        <div class="col-sm-8">
                            <input type="text" id="new_folder_name" name="new_folder" class="form-control">
                        </div>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">
                        取消
                    </button>
                    <button type="submit" class="btn btn-primary">
                        创建新目录
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>

{{-- 删除文件 --}}
<div class="modal fade" id="modal-file-delete">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h4 class="modal-title">请确认</h4>
                <button type="button" class="close" data-dismiss="modal">
                    ×
                </button>
            </div>
            <div class="modal-body">
                <p class="lead">
                    <i class="fa fa-question-circle fa-lg"></i>
                    确定要删除
                    <kbd><span id="delete-file-name1">file</span></kbd>
                    这个文件吗?
                </p>
            </div>
            <div class="modal-footer">
                <form method="POST" action="/admin/upload/file">
                    <input type="hidden" name="_token" value="{{ csrf_token() }}">
                    <input type="hidden" name="_method" value="DELETE">
                    <input type="hidden" name="folder" value="{{ $folder }}">
                    <input type="hidden" name="del_file" id="delete-file-name2">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">
                        取消
                    </button>
                    <button type="submit" class="btn btn-danger">
                        删除文件
                    </button>
                </form>
            </div>
        </div>
    </div>
</div>

{{-- 删除目录 --}}
<div class="modal fade" id="modal-folder-delete">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h4 class="modal-title">请确认</h4>
                <button type="button" class="close" data-dismiss="modal">
                    ×
                </button>
            </div>
            <div class="modal-body">
                <p class="lead">
                    <i class="fa fa-question-circle fa-lg"></i>
                    确定要删除
                    <kbd><span id="delete-folder-name1">folder</span></kbd>
                    这个目录吗?
                </p>
            </div>
            <div class="modal-footer">
                <form method="POST" action="/admin/upload/folder">
                    <input type="hidden" name="_token" value="{{ csrf_token() }}">
                    <input type="hidden" name="_method" value="DELETE">
                    <input type="hidden" name="folder" value="{{ $folder }}">
                    <input type="hidden" name="del_folder" id="delete-folder-name2">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">
                        取消
                    </button>
                    <button type="submit" class="btn btn-danger">
                        删除目录
                    </button>
                </form>
            </div>
        </div>
    </div>
</div>

{{-- 上传文件 --}}
<div class="modal fade" id="modal-file-upload">
    <div class="modal-dialog">
        <div class="modal-content">
            <form method="POST" action="/admin/upload/file" enctype="multipart/form-data">
                <input type="hidden" name="_token" value="{{ csrf_token() }}">
                <input type="hidden" name="folder" value="{{ $folder }}">
                <div class="modal-header">
                    <h4 class="modal-title">上传新文件</h4>
                    <button type="button" class="close" data-dismiss="modal">
                        ×
                    </button>
                </div>
                <div class="modal-body">
                    <div class="form-group row">
                        <label for="file" class="col-sm-3 col-form-label">
                            文件
                        </label>
                        <div class="col-sm-8">
                            <input type="file" id="file" name="file">
                        </div>
                    </div>
                    <div class="form-group row">
                        <label for="file_name" class="col-sm-3 col-form-label">
                            文件名(可选)
                        </label>
                        <div class="col-sm-8">
                            <input type="text" id="file_name" name="file_name" class="form-control">
                        </div>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">
                        取消
                    </button>
                    <button type="submit" class="btn btn-primary">
                        上传文件
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>

{{-- 浏览图片 --}}
<div class="modal fade" id="modal-image-view">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h4 class="modal-title">图片预览</h4>
                <button type="button" class="close" data-dismiss="modal">
                    ×
                </button>
            </div>
            <div class="modal-body">
                <img id="preview-image" src="x" class="img-responsive">
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">
                    取消
                </button>
            </div>
        </div>
    </div>
</div>

在该文件中总共有5个不同的模态弹出框,分别对应上面定义的5个路由。

添加表单请求验证类

使用 Artisan 命令创建 UploadFileRequest,并编辑其内容如下:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadFileRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'file' => 'required',
            'folder' => 'required',
        ];
    }
}

使用 Artisan 命令创建 UploadNewFolderRequest,并编辑其内容如下:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadNewFolderRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'folder' => 'required',
            'new_folder' => 'required',
        ];
    }
}

同样,这些请求类用于验证表单输入。

完成 UploadController 所有方法

编辑 UploadController.php 文件内容如下:

// 添加如下三个 use 语句到 UploadController 控制器顶部
use App\Http\Requests\UploadFileRequest;
use App\Http\Requests\UploadNewFolderRequest;
use Illuminate\Support\Facades\File;

// 添加如下四个方法到UploadController控制器类
/**
 * 创建新目录
 */
public function createFolder(UploadNewFolderRequest $request)
{
    $new_folder = $request->get('new_folder');
    $folder = $request->get('folder') . '/' . $new_folder;

    $result = $this->manager->createDirectory($folder);

    if ($result === true) {
        return redirect()
            ->back()
            ->with('success', '目录「' . $new_folder . '」创建成功.');
    }

    $error = $result ?: "创建目录出错.";
    return redirect()
        ->back()
        ->withErrors([$error]);
}

/**
 * 删除文件
 */
public function deleteFile(Request $request)
{
    $del_file = $request->get('del_file');
    $path = $request->get('folder') . '/' . $del_file;

    $result = $this->manager->deleteFile($path);

    if ($result === true) {
        return redirect()
            ->back()
            ->with('success', '文件「' . $del_file . '」已删除.');
    }

    $error = $result ?: "文件删除出错.";
    return redirect()
        ->back()
        ->withErrors([$error]);
}

/**
 * 删除目录
 */
public function deleteFolder(Request $request)
{
    $del_folder = $request->get('del_folder');
    $folder = $request->get('folder') . '/' . $del_folder;

    $result = $this->manager->deleteDirectory($folder);

    if ($result === true) {
        return redirect()
            ->back()
            ->with('success', '目录「' . $del_folder . '」已删除');
    }

    $error = $result ?: "An error occurred deleting directory.";
    return redirect()
        ->back()
        ->withErrors([$error]);
}

/**
 * 上传文件
 */
public function uploadFile(UploadFileRequest $request)
{
    $file = $_FILES['file'];
    $fileName = $request->get('file_name');
    $fileName = $fileName ?: $file['name'];
    $path = str_finish($request->get('folder'), '/') . $fileName;
    $content = File::get($file['tmp_name']);

    $result = $this->manager->saveFile($path, $content);

    if ($result === true) {
        return redirect()
            ->back()
            ->with("success", '文件「' . $fileName . '」上传成功.');
    }

    $error = $result ?: "文件上传出错.";
    return redirect()
        ->back()
        ->withErrors([$error]);
}

完成 UploadsManager 服务类

最后在 app/Services/UploadsManager.php 服务类中添加如下方法:

// 在该类中新增以下4个方法
/**
 * 创建新目录
 */
public function createDirectory($folder)
{
    $folder = $this->cleanFolder($folder);

    if ($this->disk->exists($folder)) {
        return "Folder '$folder' already exists.";
    }

    return $this->disk->makeDirectory($folder);
}

/**
 * 删除目录
 */
public function deleteDirectory($folder)
{
    $folder = $this->cleanFolder($folder);

    $filesFolders = array_merge(
        $this->disk->directories($folder),
        $this->disk->files($folder)
    );
    if (! empty($filesFolders)) {
        return "Directory must be empty to delete it.";
    }

    return $this->disk->deleteDirectory($folder);
}

/**
 * 删除文件
 */
public function deleteFile($path)
{
    $path = $this->cleanFolder($path);

    if (! $this->disk->exists($path)) {
        return "File does not exist.";
    }

    return $this->disk->delete($path);
}

/**
 * 保存文件
 */
public function saveFile($path, $content)
{
    $path = $this->cleanFolder($path);

    if ($this->disk->exists($path)) {
        return "File already exists.";
    }

    return $this->disk->put($path, $content);
}

至此,已经完成了文件上传管理的所有工作,可以去浏览器完成上传文件、创建目录、删除文件等操作了。

6、测试文件上传和删除功能

在浏览器中访问 http://blog57.test/admin/upload,点击「新增目录」按钮创建新目录,在弹出的模态对话框中填写表单:

点击「创建新目录」按钮提交表单,创建目录成功,跳转到上传列表页:

点击进入新创建的子目录 LaravelAcademy,在该目录下点击「上传」按钮上传文件:

点击「上传文件」按钮,上传成功后跳转到上传列表页:

但是去 public/storage/LaravelAcademy 目录下查看,上传的文件 Laravel学院 并没有扩展名,而且上面列表里 Type 类型值为 Unkown,预览按钮没显示出来也说明了有问题,正确的文件名应该包含扩展名,看来是上传图片时填写的文件名称有问题,应该这样填写为 LaravelAcademy.png,这样重新上传后文件列表显示如下:

这样数据都对了,预览按钮也显示出来了,点击「预览」按钮,页面显示如下:

最后我们将无效的「Laravel学院」文件删除,点击其对应的「删除」按钮,页面弹出确认删除对话框:

点击「删除文件」,确认删除。

学院君 has written 1137 articles

Laravel学院院长,终身学习者

积分:144522 等级:P12 职业:手艺人 城市:杭州

40 条回复

  1. pt922 pt922 says:

    你好,点击上传按钮,视图文件报错Symfony \ Component \ Debug \ Exception \ FatalThrowableError (E_ERROR) Call to a member function folderInfo() on null, 已在类中引入了UploadsManager,但是调不到folderInfo()方法

  2. 学院君 学院君 says:
    @ Lee

    确实要加上 storage 路径 你根据自己项目真实的图片路径来 不要照搬照套 尽信书不如无书

登录后才能进行评论,立即登录?