📤 文件上传

ThinkAdmin 的后台文件上传由 admin/api.upload 控制器、前端上传适配器和 think\admin\Storage 存储驱动协同完成。标准流程支持后台用户上传,也支持通过 AdminService::withUploadToken() 生成的会员上传会话。

上传功能本身只负责文件接收、存储授权、秒传判断和 system_file 状态记录。业务表单通常只保存最终返回的文件 URL 或安全文件键。

上传流程

标准后台上传流程如下:

  1. 浏览器加载 /admin/api.upload/index 输出的上传脚本配置。
  2. 用户选择文件后,前端按配置计算文件键和 xmd5
  3. 前端请求 /admin/api.upload/state
  4. 服务端写入一条 system_file 状态为 1 的记录,并检查文件是否已经存在。
  5. 已存在时返回业务码 200,前端直接完成秒传。
  6. 不存在时返回业务码 404 和本地或云存储上传授权。
  7. 前端上传文件到本地接口或云存储服务商。
  8. 上传成功后调用 /admin/api.upload/done,将 system_file.status 更新为 2

业务码 404 在这里表示“需要继续上传”,不是 HTTP 404 异常。

接口说明

接口方法作用
/admin/api.upload/indexGET输出前端上传脚本、允许后缀 MIME 配置和命名方式
/admin/api.upload/statePOST创建文件记录,检查秒传,返回上传授权参数
/admin/api.upload/filePOST本地存储上传入口,或非直传场景的文件接收入口
/admin/api.upload/donePOST上传完成确认,更新文件状态
/admin/api.upload/imageGET后台图片选择器

脚本配置接口不会强制要求后台登录。若请求已经携带并写入了会员上传会话,会按会员令牌后缀与 storage.allow_exts 的交集输出允许后缀;否则输出系统允许后缀。文件选择、状态检查、文件上传和完成确认都需要后台登录会话,或有效的会员上传会话。

身份与后缀限制

上传接口通过 AdminService::getUserId() 判断后台用户,通过 AdminService::withUploadUnid() 读取会员上传会话。若不是后台用户而是会员上传令牌,允许后缀会取令牌后缀与 storage.allow_exts 的交集。

use think\admin\service\AdminService;

// 生成会员上传会话,限制会员只能上传 jpg、png
$token = AdminService::withUploadToken($memberId, 'jpg,png');

前端公共页面通常通过 admin/api.plugs/script?uptoken=... 写入会员上传会话,后续上传接口再通过 AdminService::withUploadUnid() 读取会员编号和允许后缀。

服务端上传时还会执行这些检查:

  • 文件路径不能包含 ..
  • 存储键后缀必须与原始上传文件后缀一致。
  • 后缀必须在 storage.allow_exts 配置中。
  • 会员上传还必须在上传令牌允许后缀中。
  • 禁止 shaspbatcmdexephp
  • 本地图片会检查图片尺寸,并拦截包含 PHP、ASP 或脚本片段的异常内容。

返回数据

state 秒传成功时会返回业务码 200,其中 data.url 是最终文件地址或安全文件键:

{
    "code": 200,
    "info": "文件已经上传",
    "data": {
        "id": 100,
        "url": "https://example.com/upload/ab/cdef.jpg",
        "key": "ab/cdef.jpg",
        "safe": 0,
        "uptype": "local"
    }
}

秒传成功时,data.key 来自存储驱动 info() 返回值。本地存储会返回 upload/{对象键},云存储和 Alist 一般返回对象键本身。业务表单通常只使用 data.url

state 需要继续上传时会返回业务码 404,并附带上传入口与授权字段:

{
    "code": 404,
    "info": "获取上传授权参数",
    "data": {
        "id": 101,
        "server": "https://example.com/admin/api.upload/file",
        "url": "https://example.com/upload/ab/cdef.jpg",
        "key": "ab/cdef.jpg",
        "safe": 0,
        "uptype": "local"
    }
}

继续上传时,data.key 是前端提交的对象键。不同存储驱动会额外返回直传所需字段,前端适配器按 uptype 读取:

驱动额外字段前端提交方式
localPOST 到 /admin/api.upload/file,表单包含 keysafeuptypefile
qiniutokenPOST 到七牛上传地址,表单包含 keytokenfile
aliosspolicysignatureOSSAccessKeyIdPOST 到 OSS 地址,并追加 success_action_status=200Content-Disposition
txcospolicyq-akq-key-timeq-signatureq-sign-algorithmPOST 到 COS 地址,并追加 success_action_status=200Content-Disposition
upyunpolicyauthorizationPOST 到又拍云地址,表单使用 save-keypolicyauthorizationContent-Dispositionfile
alistfilepathauthorizationPUT 到 Alist /api/fs/form,请求头写入 file-pathauthorization,表单只提交 file

这些字段用于上传适配器完成直传。业务代码通常只关心最终写入表单的 url

安全模式下,本地上传成功后返回的是文件键,不是公开 URL。

前端入口

admin.js 提供了底层上传入口和常用封装:

// 底层上传入口,可由声明式 data-file 自动初始化,也可由业务脚本手动初始化
$(element).uploadFile();

// 单视频上传封装
$('[name=video]').uploadOneVideo();

// 单图片上传封装
$('[name=avatar]').uploadOneImage();

// 多图片上传封装
$('[name=images]').uploadMultipleImage();

常见单图示例:

<input type="text" name="avatar" value="">

<script>
    $('[name=avatar]').uploadOneImage();

    $('[name=avatar]').on('change', function () {
        $('#preview').attr('src', this.value);
    });
</script>

也可以直接使用 data-file

<input type="text" name="FileInput" value="">
<button type="button" data-file data-field="FileInput" data-type="jpg,png">上传文件</button>

点击带 data-file 的元素时,admin.js 会自动查找 data-field 指向的 input,并在上传完成后写入值。

如果业务已经拿到了 input DOM,也可以通过 data-input 或 jQuery .data('input', input) 直接指定回写目标。需要自定义上传完成逻辑时,可以手动调用 .uploadFile(callback);这类按钮不建议再写成会被页面扫描自动接管的声明式按钮。

data-file 行为

data-file 的取值会影响行为。点击时会先判断 image / images 图片选择器,其他值再进入上传组件:

写法行为
data-file="image"打开后台图片选择器,选择单图
data-file="images"打开后台图片选择器,选择多图
data-filedata-file="one"data-file="btn"单文件上传
data-file="mul" 或其他非 one、非 btn、非图片选择器值多文件上传

uploadFile() 会把 data-file 转换为内部 data-multiple 开关,上传适配器实际读取的是 data-multipleuploadOneImage()uploadOneVideo()uploadMultipleImage() 会自动生成内部上传按钮,并复制原 input 上的上传属性。

页面动态渲染完成后,$.form.reInit() 只会自动初始化能够明确回写目标的直接上传按钮,例如带 data-fielddata-input,或图片选择器弹窗内部的上传按钮。data-file="image"data-file="images" 始终是图片选择器入口,不会被当作直接上传按钮初始化。包装组件内部生成的真实上传按钮由组件自己显式初始化,因此第一次点击上传图标也会直接打开文件选择框。

没有 data-fielddata-input 的临时按钮通常应由业务脚本后续 .uploadFile(callback) 接管,页面扫描不会提前抢占。这样可以避免 CKEditor、设计器、区块链表单等手动上传场景丢失 callback。

上传参数

属性说明
data-field上传成功后写入的 input 名称,按 input[name="..."] 查找
data-input上传成功后写入的 input DOM,通常由封装组件或业务脚本写入 jQuery data
data-type前端允许选择的后缀,多个用逗号分隔
data-size前端文件大小限制,单位 Byte
data-path存储键前缀,会被清理为安全路径
data-uptype指定存储驱动,不填则使用 storage.type
data-safe安全上传,前端会强制使用本地存储
data-multiple底层多选开关,通常由 data-file 自动生成
data-hload隐藏上传过程通知
data-quality图片压缩质量,默认 1.0
data-max-width图片最大宽度
data-max-height图片最大高度
data-cut-width图片裁剪宽度
data-cut-height图片裁剪高度

data-sizedata-type 主要影响前端选择和提示。服务端仍以 storage.allow_exts、上传令牌后缀和业务自定义校验为准。

默认允许后缀由后台配置决定。若前端写了 data-type="jpg,jpeg",但后台 storage.allow_exts 未包含 jpeg.jpeg 文件仍会被服务端拒绝。

存储路径

前端上传脚本会按 storage.name_type 生成文件键;接口未读到配置时按 xmd5 处理:

  • 默认 MD5 模式:ab/cdef1234567890abcdef1234567890.jpg
  • 日期随机模式:先生成 yyyyMMddHHmmss-随机数字,再整理为 yyyyMM/ddHHmmss-random.jpg 形式。该模式仍会提交一个随机补齐的 xmd5,但不计算真实文件内容 MD5,因此不支持基于内容的秒传。

设置 data-path 后会加上业务前缀:

<input type="text" name="avatar" value="">
<button type="button" data-file data-field="avatar" data-type="jpg,png" data-path="avatar">
    上传头像
</button>

最终键类似:

avatar/ab/cdef1234567890abcdef1234567890.jpg

图片处理

图片处理发生在浏览器端。非 GIF 图片在设置了尺寸、裁剪或压缩参数时,会先经过 compressor 处理再上传:

<button type="button"
        data-file
        data-field="cover"
        data-type="jpg,png"
        data-quality="0.85"
        data-max-width="1200"
        data-max-height="1200">
    上传封面
</button>
<button type="button"
        data-file
        data-field="avatar"
        data-type="jpg,png"
        data-cut-width="300"
        data-cut-height="300">
    上传头像
</button>

处理后的文件会重新计算 xmd5xkey,秒传判断基于处理后的内容。

上传事件

upload.start 外,上传适配器通过 triggerHandler() 触发事件,按钮元素和绑定的 input 都会收到事件。upload.start 是 ThinkAdmin 自定义事件,由 admin.jsdata-file 点击初始化上传组件后通过 trigger() 触发,只作用于当前点击元素。初始化完成后,admin.js 还会同步触发 Layui 当前使用的 click.lay_upload_start,确保首次点击即可打开文件选择框。

事件数据
upload.start无参数,点击 data-file 初始化上传组件后在当前元素触发
upload.choose选择的文件列表
upload.hash当前文件对象,包含 xmd5xkeyxext
upload.progress{number, file}
push单个文件上传成功后的文件地址
upload.done{file, data}
upload.complete{file},其中 file 是本轮文件集合
upload.error{file},错误说明在 file.xstats

示例:

<input type="text" name="FileInput" value="">
<button type="button" data-file data-field="FileInput" data-type="jpg,png">上传文件</button>

<script>
    $('[data-file]')
        .on('upload.start', function () {
            console.log('上传组件已初始化');
        })
        .on('upload.hash', function (event, file) {
            console.log('文件 MD5:', file.xmd5);
            console.log('存储键:', file.xkey);
        })
        .on('upload.progress', function (event, data) {
            console.log('上传进度:', data.number + '%');
        })
        .on('upload.done', function (event, data) {
            console.log('文件名:', data.file.name);
            console.log('文件地址:', data.file.xurl);
        })
        .on('push', function (event, url) {
            console.log('上传地址:', url);
        })
        .on('upload.error', function (event, data) {
            console.log('上传失败:', data.file.xstats);
        });

    $('[name=FileInput]').on('change', function () {
        console.log('表单值:', this.value);
    });
</script>

多文件上传完成后,绑定 input 的值会用 | 拼接多个文件地址。

包装组件会基于原 input 自动创建按钮和预览区:

  • uploadOneImage():生成上传、预览和清空控件,上传完成后把单图地址写回原 input。
  • uploadOneVideo():生成上传、预览和清空控件,默认限制 mp4,上传完成后把视频地址写回原 input。
  • uploadMultipleImage():生成多图上传和选择器入口,单个文件成功时触发 push,最终把多图地址按 | 拼接写回原 input。

自定义后端上传

标准后台上传接口已经覆盖常见场景。若业务必须自定义上传接口,应按实际存储接口生成文件键并保存:

<?php
declare(strict_types=1);

namespace app\admin\controller\api;

use think\admin\Controller;
use think\admin\Storage;

class Upload extends Controller
{
    /**
     * @login true
     */
    public function index()
    {
        $file = $this->request->file('file');
        if (empty($file)) {
            $this->error('请选择要上传的文件');
        }

        $ext = strtolower($file->extension());
        $allowExts = str2arr(sysconf('storage.allow_exts|raw'));
        if (!in_array($ext, $allowExts)) {
            $this->error('不支持的文件类型!');
        }

        if ($file->getSize() > 5 * 1024 * 1024) {
            $this->error('文件大小超过限制!');
        }

        $filename = Storage::name($file->getPathname(), $ext, 'upload', 'md5_file');
        $result = Storage::instance()->set($filename, file_get_contents($file->getPathname()));

        if (isset($result['url'])) {
            $this->success('上传成功', $result);
        }

        $this->error('上传失败,请稍后重试!');
    }
}

Storage::name($file->getPathname(), $ext, 'upload') 默认会对临时文件路径字符串取 MD5;若要按文件内容生成键,需要传入第四个参数 md5_file

常见问题

上传提示“不支持的文件类型”怎么办?

依次检查:

  • data-type 是否包含该后缀。
  • 后台 storage.allow_exts 是否包含该后缀。
  • 会员上传令牌是否允许该后缀。
  • 文件真实扩展名是否与生成的存储键后缀一致。

如何实现文件秒传?

标准上传组件已经内置秒传。前端计算 xmd5xkey 后请求 /admin/api.upload/state,服务端通过存储驱动的 info() 判断文件是否存在。存在时返回业务码 200,前端直接触发完成流程。

一般不需要额外编写独立的文件存在检查接口。

为什么安全上传返回的不是 URL?

data-safe="true" 会强制走本地安全目录。安全文件写入 safefile,接口返回文件键,业务需要通过自己的下载接口鉴权后读取。

上传大文件时怎么处理?

当前标准组件不是分片上传。可以使用云存储直传减少服务器带宽压力,并通过 upload.progress 展示进度。若需要断点续传或分片合并,需要按业务自定义实现。

云存储配置在哪里?

在后台系统参数的文件存储配置中维护存储类型、访问密钥、区域、空间名称和访问域名。具体可结合 文件存储 章节查看存储驱动说明。

最近更新:
Contributors: 邹景立, Anyon