📤 文件上传
ThinkAdmin 的后台文件上传由 admin/api.upload 控制器、前端上传适配器和 think\admin\Storage 存储驱动协同完成。标准流程支持后台用户上传,也支持通过 AdminService::withUploadToken() 生成的会员上传会话。
上传功能本身只负责文件接收、存储授权、秒传判断和 system_file 状态记录。业务表单通常只保存最终返回的文件 URL 或安全文件键。
上传流程
标准后台上传流程如下:
- 浏览器加载
/admin/api.upload/index输出的上传脚本配置。 - 用户选择文件后,前端按配置计算文件键和
xmd5。 - 前端请求
/admin/api.upload/state。 - 服务端写入一条
system_file状态为1的记录,并检查文件是否已经存在。 - 已存在时返回业务码
200,前端直接完成秒传。 - 不存在时返回业务码
404和本地或云存储上传授权。 - 前端上传文件到本地接口或云存储服务商。
- 上传成功后调用
/admin/api.upload/done,将system_file.status更新为2。
业务码 404 在这里表示“需要继续上传”,不是 HTTP 404 异常。
接口说明
| 接口 | 方法 | 作用 |
|---|---|---|
/admin/api.upload/index | GET | 输出前端上传脚本、允许后缀 MIME 配置和命名方式 |
/admin/api.upload/state | POST | 创建文件记录,检查秒传,返回上传授权参数 |
/admin/api.upload/file | POST | 本地存储上传入口,或非直传场景的文件接收入口 |
/admin/api.upload/done | POST | 上传完成确认,更新文件状态 |
/admin/api.upload/image | GET | 后台图片选择器 |
脚本配置接口不会强制要求后台登录。若请求已经携带并写入了会员上传会话,会按会员令牌后缀与 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配置中。 - 会员上传还必须在上传令牌允许后缀中。
- 禁止
sh、asp、bat、cmd、exe、php。 - 本地图片会检查图片尺寸,并拦截包含 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 读取:
| 驱动 | 额外字段 | 前端提交方式 |
|---|---|---|
local | 无 | POST 到 /admin/api.upload/file,表单包含 key、safe、uptype 和 file |
qiniu | token | POST 到七牛上传地址,表单包含 key、token、file |
alioss | policy、signature、OSSAccessKeyId | POST 到 OSS 地址,并追加 success_action_status=200 与 Content-Disposition |
txcos | policy、q-ak、q-key-time、q-signature、q-sign-algorithm | POST 到 COS 地址,并追加 success_action_status=200 与 Content-Disposition |
upyun | policy、authorization | POST 到又拍云地址,表单使用 save-key、policy、authorization、Content-Disposition、file |
alist | filepath、authorization | PUT 到 Alist /api/fs/form,请求头写入 file-path 和 authorization,表单只提交 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-file、data-file="one"、data-file="btn" | 单文件上传 |
data-file="mul" 或其他非 one、非 btn、非图片选择器值 | 多文件上传 |
uploadFile() 会把 data-file 转换为内部 data-multiple 开关,上传适配器实际读取的是 data-multiple。uploadOneImage()、uploadOneVideo() 和 uploadMultipleImage() 会自动生成内部上传按钮,并复制原 input 上的上传属性。
页面动态渲染完成后,$.form.reInit() 只会自动初始化能够明确回写目标的直接上传按钮,例如带 data-field、data-input,或图片选择器弹窗内部的上传按钮。data-file="image" 与 data-file="images" 始终是图片选择器入口,不会被当作直接上传按钮初始化。包装组件内部生成的真实上传按钮由组件自己显式初始化,因此第一次点击上传图标也会直接打开文件选择框。
没有 data-field 或 data-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-size 和 data-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>处理后的文件会重新计算 xmd5 和 xkey,秒传判断基于处理后的内容。
上传事件
除 upload.start 外,上传适配器通过 triggerHandler() 触发事件,按钮元素和绑定的 input 都会收到事件。upload.start 是 ThinkAdmin 自定义事件,由 admin.js 在 data-file 点击初始化上传组件后通过 trigger() 触发,只作用于当前点击元素。初始化完成后,admin.js 还会同步触发 Layui 当前使用的 click.lay_upload_start,确保首次点击即可打开文件选择框。
| 事件 | 数据 |
|---|---|
upload.start | 无参数,点击 data-file 初始化上传组件后在当前元素触发 |
upload.choose | 选择的文件列表 |
upload.hash | 当前文件对象,包含 xmd5、xkey、xext |
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是否包含该后缀。 - 会员上传令牌是否允许该后缀。
- 文件真实扩展名是否与生成的存储键后缀一致。
如何实现文件秒传?
标准上传组件已经内置秒传。前端计算 xmd5 和 xkey 后请求 /admin/api.upload/state,服务端通过存储驱动的 info() 判断文件是否存在。存在时返回业务码 200,前端直接触发完成流程。
一般不需要额外编写独立的文件存在检查接口。
为什么安全上传返回的不是 URL?
data-safe="true" 会强制走本地安全目录。安全文件写入 safefile,接口返回文件键,业务需要通过自己的下载接口鉴权后读取。
上传大文件时怎么处理?
当前标准组件不是分片上传。可以使用云存储直传减少服务器带宽压力,并通过 upload.progress 展示进度。若需要断点续传或分片合并,需要按业务自定义实现。
云存储配置在哪里?
在后台系统参数的文件存储配置中维护存储类型、访问密钥、区域、空间名称和访问域名。具体可结合 文件存储 章节查看存储驱动说明。
