From 184fb2768067ec0ae28a4a9dcfa69692eabef22e Mon Sep 17 00:00:00 2001 From: kuaifan Date: Tue, 30 Jun 2026 03:54:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(upload):=20=E5=88=86=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E7=BB=9F=E4=B8=80=E9=93=BE=E8=B7=AF=EF=BC=8C5=20?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E7=AA=81=E7=A0=B4=E5=8D=95=E6=96=87=E4=BB=B6?= =?UTF-8?q?=201G=20=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 api/upload/{init,chunk,merge} 入口与 ChunkUpload 模块(5MB 分片、3 并发、Redis 状态机) - 5 场景接入:文件柜 / 聊天 / 任务附件 / 头像&系统图片 / 编辑器粘贴 - 秒传:同用户同 hash 复用 FileContent 物理文件,零传输 - 续传:Redis + localStorage 双索引,24h TTL - 与老接口对齐:pid 锁、≤300 上限、webkit_relative_path 目录递归、overwrite 替换语义 - init 阶段读 file_upload_limit 拦截超限,避免传完分片才报错 - DeleteTmpTask 加 tmp_chunks case 兜底清理 24h 未合并目录 - files 表新增 hash 列(migration) - 前端 chunkedUpload wrapper:主线程 spark-md5 流式 + 指数退避重试 - ai-kb 同步:upload / file-upload-limit / upload-size-limit 三个 chunk --- app/Http/Controllers/Api/UploadController.php | 129 +++++ app/Models/File.php | 128 +++-- app/Models/WebSocketDialog.php | 122 ++++- app/Module/Base.php | 38 +- app/Module/ChunkUpload.php | 511 ++++++++++++++++++ app/Tasks/DeleteTmpTask.php | 21 + ...6_06_30_001026_add_hash_to_files_table.php | 24 + language/original-api.txt | 19 + language/original-web.txt | 10 + package.json | 1 + .../zh/faq/common-faq/upload-size-limit.md | 19 +- resources/ai-kb/zh/howto/file/upload.md | 13 +- .../howto/system-setting/file-upload-limit.md | 19 +- resources/assets/js/components/ImgUpload.vue | 54 +- resources/assets/js/components/TEditor.vue | 26 +- .../pages/manage/components/DialogUpload.vue | 94 ++++ resources/assets/js/pages/manage/file.vue | 94 +++- .../setting/components/SystemSetting.vue | 2 +- resources/assets/js/store/chunkedUpload.js | 225 ++++++++ routes/api-map.md | 11 +- routes/web.php | 4 + tests/manual/chunk_cleanup_smoke.php | 40 ++ tests/manual/chunk_upload_smoke.php | 203 +++++++ 23 files changed, 1716 insertions(+), 91 deletions(-) create mode 100644 app/Http/Controllers/Api/UploadController.php create mode 100644 app/Module/ChunkUpload.php create mode 100644 database/migrations/2026_06_30_001026_add_hash_to_files_table.php create mode 100644 resources/assets/js/store/chunkedUpload.js create mode 100644 tests/manual/chunk_cleanup_smoke.php create mode 100644 tests/manual/chunk_upload_smoke.php diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php new file mode 100644 index 000000000..cd5b40b16 --- /dev/null +++ b/app/Http/Controllers/Api/UploadController.php @@ -0,0 +1,129 @@ + init() 启动一个上传会话(含秒传 / 续传命中) + * api/upload/chunk -> chunk() 接收一个分片 + * api/upload/merge -> merge() 合并分片并按 scene 入库 + * + * 小文件(<10MB)不走此接口,前端直接调用各 scene 的老接口(透明降级)。 + */ +class UploadController extends AbstractController +{ + /** + * @api {post} api/upload/init 启动上传会话 + * + * @apiDescription 提交文件 hash/size/name/scene/scene_params,返回 upload_id 与已收分片列表; + * 若同用户曾上传过同 hash 文件,直接返回 done=true(秒传)。 + * @apiGroup upload + * @apiName init + * + * @apiParam {String} hash 文件 md5(小写 32 字符) + * @apiParam {Number} size 文件大小(字节) + * @apiParam {String} name 原始文件名(含扩展名) + * @apiParam {String} scene 场景:file_cabinet | dialog_file | image | generic_file + * @apiParam {Object} [scene_params] 场景参数(如 file_cabinet 需 pid/cover/webkit_relative_path) + * + * @apiSuccess {Number} ret + * @apiSuccess {Object} data 含 done / upload_id / chunk_size / chunk_count / received 或秒传 file + */ + public function init() + { + $user = User::auth(); + $result = ChunkUpload::start($user, [ + 'hash' => Request::input('hash', ''), + 'size' => Request::input('size', 0), + 'name' => Request::input('name', ''), + 'scene' => Request::input('scene', ''), + 'scene_params' => Request::input('scene_params', []), + ]); + return $result; + } + + /** + * @api {post} api/upload/chunk 上传一个分片 + * + * @apiDescription multipart 请求,blob 字段为分片数据。 + * @apiGroup upload + * @apiName chunk + * + * @apiParam {String} upload_id init 返回的 upload_id + * @apiParam {Number} index 分片序号(0-based) + * @apiParam {File} blob 分片数据 + * + * @apiSuccess {Number} ret + * @apiSuccess {Object} data 含 upload_id 与最新 received[] + */ + public function chunk() + { + $user = User::auth(); + $uploadId = trim(Request::input('upload_id', '')); + $index = intval(Request::input('index', -1)); + $blob = Request::file('blob'); + if ($uploadId === '') { + return Base::retError('upload_id 不能为空'); + } + return ChunkUpload::receive($user, $uploadId, $index, $blob); + } + + /** + * @api {post} api/upload/merge 合并分片并入库 + * + * @apiDescription 全部分片到齐后调用;后端按 scene 路由到对应入库逻辑,返回与该 scene 老接口对齐的数据。 + * @apiGroup upload + * @apiName merge + * + * @apiParam {String} upload_id init 返回的 upload_id + * + * @apiSuccess {Number} ret + * @apiSuccess {Object} data scene 入库返回数据 + */ + public function merge() + { + $user = User::auth(); + $uploadId = trim(Request::input('upload_id', '')); + if ($uploadId === '') { + return Base::retError('upload_id 不能为空'); + } + try { + return ChunkUpload::merge($user, $uploadId); + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Failed to acquire lock')) { + return Base::retError('合并繁忙,请稍后再试'); + } + return Base::retError($e->getMessage()); + } + } + + /** + * @api {post} api/upload/cancel 取消上传会话 + * + * @apiDescription 调用方主动放弃一次分片上传时调用:删除 Redis meta/chunks/hash 索引并清掉分片目录。 + * 会话已过期或归属其他用户时静默成功,避免给前端取消按钮回写"取消失败"。 + * @apiGroup upload + * @apiName cancel + * + * @apiParam {String} upload_id init 返回的 upload_id + * + * @apiSuccess {Number} ret + */ + public function cancel() + { + $user = User::auth(); + $uploadId = trim(Request::input('upload_id', '')); + if ($uploadId === '') { + return Base::retError('upload_id 不能为空'); + } + ChunkUpload::cancelByUser($user, $uploadId); + return Base::retSuccess('已取消'); + } +} diff --git a/app/Models/File.php b/app/Models/File.php index 7acc9471a..9879b11f6 100644 --- a/app/Models/File.php +++ b/app/Models/File.php @@ -267,20 +267,74 @@ class File extends AbstractModel * @return array */ public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false) + { + [$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath); + $data = Base::upload([ + "file" => Request::file('files'), + "type" => 'more', + "autoThumb" => false, + "path" => 'uploads/tmp/file/' . date("Ym") . '/', + "quality" => true, + ]); + if (Base::isError($data)) { + throw new ApiException($data['msg']); + } + return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, null, $overwrite); + } + + /** + * 与 contentUpload 同一入库链路,但接收已落盘的本地文件而非 Request 上传文件。 + * 供分片上传 merge 阶段调用。 + * + * @param user $user + * @param int $pid + * @param string $localPath 合并后的完整文件绝对路径 + * @param string $originalName 原始文件名(含扩展名) + * @param string $webkitRelativePath + * @param string|null $hash 文件 md5,用于秒传索引 + * @param bool $overwrite + * @return array + */ + public function contentUploadFromPath($user, int $pid, string $localPath, string $originalName, $webkitRelativePath, $hash = null, $overwrite = false) + { + [$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath); + $data = Base::uploadFromPath([ + "path_local" => $localPath, + "name" => $originalName, + "type" => 'more', + "autoThumb" => false, + "path" => 'uploads/tmp/file/' . date("Ym") . '/', + "quality" => true, + ]); + if (Base::isError($data)) { + throw new ApiException($data['msg']); + } + return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, $hash, $overwrite); + } + + /** + * 上传前置:权限/计数校验 + webkitRelativePath 拆出来的中间文件夹创建。 + * 失败抛 ApiException;成功返回 [最终 pid, 拥有者 userid, 已创建的中间文件夹列表]。 + * + * @param user $user + * @param int $pid + * @param string $webkitRelativePath + * @return array{0:int, 1:int, 2:array} + */ + public function contentUploadPrep($user, int $pid, $webkitRelativePath): array { $userid = $user->userid; if ($pid > 0) { if (File::wherePid($pid)->count() >= 300) { - return Base::retError('每个文件夹里最多只能创建300个文件或文件夹'); + throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹'); } $row = File::permissionFind($pid, $user, 1); $userid = $row->userid; } else { if (File::whereUserid($user->userid)->wherePid(0)->count() >= 300) { - return Base::retError('每个文件夹里最多只能创建300个文件或文件夹'); + throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹'); } } - // $dirs = explode("/", $webkitRelativePath); $addItem = []; while (count($dirs) > 1) { @@ -297,12 +351,10 @@ class File extends AbstractModel 'created_id' => $user->userid, ]); $dirRow->handleDuplicateName(); - if ($dirRow->saveBeforePP()) { - $addItem[] = File::find($dirRow->id); + if (!$dirRow->saveBeforePP()) { + throw new ApiException('创建文件夹失败'); } - } - if (empty($dirRow)) { - throw new ApiException('创建文件夹失败'); + $addItem[] = File::find($dirRow->id); } $pid = $dirRow->id; }); @@ -311,20 +363,24 @@ class File extends AbstractModel } } } - // - $path = 'uploads/tmp/file/' . date("Ym") . '/'; - $data = Base::upload([ - "file" => Request::file('files'), - "type" => 'more', - "autoThumb" => false, - "path" => $path, - "quality" => true - ]); - if (Base::isError($data)) { - throw new ApiException($data['msg']); - } - $data = $data['data']; - // + return [$pid, $userid, $addItem]; + } + + /** + * 上传后置:ext → type 映射 + File 记录创建 + uploadMove + FileContent 入库。 + * + * @param user $user + * @param int $userid 目标文件拥有者 + * @param int $pid + * @param array $data Base::upload/uploadFromPath 返回的 data 部分 + * @param array $addItem prep 阶段累积的中间文件夹 + * @param string $webkitRelativePath + * @param string|null $hash 可选,写入 files.hash(秒传索引) + * @param bool $overwrite + * @return array{data: array, addItem: array} + */ + private function contentUploadCommit($user, int $userid, int $pid, array $data, array $addItem, $webkitRelativePath, $hash, bool $overwrite): array + { $type = match ($data['ext']) { 'text', 'md', 'markdown' => 'document', 'drawio' => 'drawio', @@ -356,7 +412,6 @@ class File extends AbstractModel if ($data['ext'] == 'markdown') { $data['ext'] = 'md'; } - $file = null; $params = [ 'pid' => $pid, 'name' => Base::rightDelete($data['name'], '.' . $data['ext']), @@ -365,6 +420,7 @@ class File extends AbstractModel 'userid' => $userid, 'created_id' => $user->userid, ]; + $file = null; if ($overwrite) { $file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first(); } @@ -373,11 +429,12 @@ class File extends AbstractModel $file = File::createInstance($params); $file->handleDuplicateName(); } - // 开始创建 - return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file) { + return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file, $hash) { $file->size = $data['size'] * 1024; + if ($hash) { + $file->hash = $hash; + } $file->saveBeforePP(); - // $data = Base::uploadMove($data, "uploads/file/" . $file->type . "/" . date("Ym") . "/" . $file->id . "/"); $content = [ 'from' => '', @@ -389,25 +446,20 @@ class File extends AbstractModel $content['width'] = $data['width']; $content['height'] = $data['height']; } - $content = FileContent::createInstance([ + FileContent::createInstance([ 'fid' => $file->id, 'content' => $content, 'text' => '', 'size' => $file->size, 'userid' => $user->userid, - ]); - $content->save(); - // + ])->save(); $tmpRow = File::find($file->id); $tmpRow->pushMsg('add', $tmpRow); - // - $data = File::handleImageUrl($tmpRow->toArray()); - $data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']); - $data['overwrite'] = $overwrite ? 1 : 0; - // - $addItem[] = $data; - - return ['data' => $data, 'addItem' => $addItem]; + $row = File::handleImageUrl($tmpRow->toArray()); + $row['full_name'] = $webkitRelativePath ?: ($row['name'] . '.' . $row['ext']); + $row['overwrite'] = $overwrite ? 1 : 0; + $addItem[] = $row; + return ['data' => $row, 'addItem' => $addItem]; }); } diff --git a/app/Models/WebSocketDialog.php b/app/Models/WebSocketDialog.php index de201952d..f7641e5ad 100644 --- a/app/Models/WebSocketDialog.php +++ b/app/Models/WebSocketDialog.php @@ -1100,6 +1100,46 @@ class WebSocketDialog extends AbstractModel }); } + /** + * 与 sendMsgFiles 同链路,但接收已落盘的本地文件(分片合并产物),跳过 Base::upload。 + * + * @param User $user + * @param int[] $dialogIds + * @param string $localPath 已落盘绝对路径 + * @param string $originalName 原始文件名 + * @param int $replyId + * @param bool $imageAttachment 任务群组中图片是否也作为附件保存 + * @return array + */ + public static function sendMsgFilesFromPath($user, $dialogIds, string $localPath, string $originalName, int $replyId = 0, bool $imageAttachment = false) + { + $first = null; + $fileName = $originalName; + $resolve = function ($path) use (&$first, &$fileName, $localPath, $originalName) { + if ($first !== null) { + return self::copyFileDataTo($first, $path); + } + $setting = Base::setting("system"); + $data = Base::uploadFromPath([ + "path_local" => $localPath, + "name" => $originalName, + "type" => 'more', + "path" => $path, + "fileName" => $fileName, + "quality" => true, + "convertVideo" => $setting['convert_video'] === 'open', + "compressVideo" => $setting['compress_video'] === 'open', + ]); + if (Base::isError($data)) { + throw new ApiException($data['msg']); + } + $first = $data['data']; + $fileName = $first['name']; + return $first; + }; + return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve); + } + /** * 发送消息文件 * @@ -1114,24 +1154,18 @@ class WebSocketDialog extends AbstractModel */ public static function sendMsgFiles($user, $dialogIds, $files, $image64, $fileName, $replyId, $imageAttachment) { - $filePath = ''; - $result = []; - $data = []; - foreach ($dialogIds as $dialog_id) { - $dialog = WebSocketDialog::checkDialog($dialog_id); - - $action = $replyId > 0 ? "reply-$replyId" : ""; - $path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/"; + $first = null; + $resolve = function ($path) use (&$first, &$fileName, $files, $image64) { + if ($first !== null) { + return self::copyFileDataTo($first, $path); + } if ($image64) { $data = Base::image64save([ "image64" => $image64, "path" => $path, "fileName" => $fileName, - "quality" => true + "quality" => true, ]); - } else if ($filePath) { - Base::makeDir(public_path($path)); - copy($filePath, public_path($path) . basename($filePath)); } else { $setting = Base::setting("system"); $data = Base::upload([ @@ -1147,19 +1181,41 @@ class WebSocketDialog extends AbstractModel if (Base::isError($data)) { throw new ApiException($data['msg']); } - $fileData = $data['data']; - $filePath = $fileData['file']; - $fileName = $fileData['name']; - $fileData['thumb'] = Base::unFillUrl($fileData['thumb']); - $fileData['size'] *= 1024; + $first = $data['data']; + $fileName = $first['name']; + return $first; + }; + return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve); + } - // 任务群组保存文件 + /** + * 遍历多个 dialog 发送文件消息:每个 dialog 取一份 fileData → 任务群组建附件 → sendMsg。 + * 取 fileData 的策略由 $resolve(path) 决定(首次 upload,后续 copy)。 + * + * @param User $user + * @param int[] $dialogIds + * @param int $replyId + * @param bool $imageAttachment + * @param callable $resolve fn(string $path): array 返回该 dialog 的 fileData + * @return array sendMsg 的最终返回(最后一个 dialog 的结果) + */ + private static function dispatchFileMessages($user, array $dialogIds, int $replyId, bool $imageAttachment, callable $resolve): array + { + $result = []; + foreach ($dialogIds as $dialog_id) { + $dialog = WebSocketDialog::checkDialog($dialog_id); + $action = $replyId > 0 ? "reply-$replyId" : ""; + $path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/"; + $fileData = $resolve($path); + $fileData['thumb'] = Base::unFillUrl($fileData['thumb'] ?? ''); + $fileData['size'] *= 1024; + $task = null; if ($dialog->group_type === 'task') { - // 如果是图片不保存 + // 图片消息默认不作为任务附件存档,除非显式 $imageAttachment if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) { $task = ProjectTask::whereDialogId($dialog->id)->first(); if ($task) { - $file = ProjectTaskFile::createInstance([ + ProjectTaskFile::createInstance([ 'project_id' => $task->project_id, 'task_id' => $task->id, 'name' => $fileData['name'], @@ -1168,20 +1224,30 @@ class WebSocketDialog extends AbstractModel 'path' => $fileData['path'], 'thumb' => $fileData['thumb'], 'userid' => $user->userid, - ]); - $file->save(); + ])->save(); } } } - - // 发送消息 $result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid); - if (Base::isSuccess($result)) { - if (isset($task)) { - $result['data']['task_id'] = $task->id; - } + if (Base::isSuccess($result) && $task) { + $result['data']['task_id'] = $task->id; } } return $result; } + + /** + * 把首个 dialog 上传得到的物理文件 copy 到后续 dialog 的目录,返回更新后的 fileData。 + */ + private static function copyFileDataTo(array $first, string $path): array + { + Base::makeDir(public_path($path)); + $target = public_path($path) . basename($first['file']); + copy($first['file'], $target); + $copy = $first; + $copy['file'] = $target; + $copy['path'] = $path . basename($first['file']); + $copy['url'] = Base::fillUrl($copy['path']); + return $copy; + } } diff --git a/app/Module/Base.php b/app/Module/Base.php index 62376e3b3..91ae8ee88 100755 --- a/app/Module/Base.php +++ b/app/Module/Base.php @@ -2074,8 +2074,20 @@ class Base */ public static function upload($param) { + // 可选 key 默认值,下游直接访问不会 undefined index + $param += [ + 'chmod' => 0644, + 'saveName' => null, + 'scale' => null, + 'size' => 0, + 'fileName' => null, + 'quality' => null, + 'autoThumb' => null, + 'convertVideo' => null, + 'compressVideo' => null, + ]; $file = $param['file']; - $chmod = $param['chmod'] ?: 0644; + $chmod = $param['chmod']; if (empty($file)) { return Base::retError("您没有选择要上传的文件"); } @@ -2130,6 +2142,9 @@ class Base $limitSize = intval($param['size']); if ($limitSize <= 0) { $fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0)); + if ($fileUploadLimit <= 0) { + $fileUploadLimit = 1024; + } $limitSize = $fileUploadLimit * 1024; } try { @@ -2317,6 +2332,27 @@ class Base } } + /** + * 把本地文件包装成 UploadedFile(test=true) 转给 Base::upload,复用全套上传逻辑。 + * @param array $param path_local + name 为本方法特有,其余与 Base::upload 一致 + */ + public static function uploadFromPath(array $param) + { + $localPath = $param['path_local'] ?? ''; + $name = $param['name'] ?? ''; + if (!$localPath || !is_file($localPath)) { + return Base::retError('源文件不存在'); + } + if (!$name) { + $name = basename($localPath); + } + unset($param['path_local'], $param['name']); + // test=true → UploadedFile::move 走 rename(),绕开 move_uploaded_file 的 is_uploaded_file 校验 + $param['file'] = new \Illuminate\Http\UploadedFile($localPath, $name, null, null, true); + $param['fileName'] = $param['fileName'] ?? $name; + return self::upload($param); + } + /** * 上传文件移动 * @param array $uploadResult diff --git a/app/Module/ChunkUpload.php b/app/Module/ChunkUpload.php new file mode 100644 index 000000000..f41f189ad --- /dev/null +++ b/app/Module/ChunkUpload.php @@ -0,0 +1,511 @@ + self::MAX_FILE_KB) { + return Base::retError('文件超过系统支持的最大尺寸'); + } + // init 时拦截系统配置上限,避免传完分片才在 merge 阶段被 Base::upload 拒绝 + $fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0)); + if ($fileUploadLimit <= 0) { + $fileUploadLimit = 1024; + } + if ($size > $fileUploadLimit * 1024 * 1024) { + return Base::retError('文件大小超限,最大限制:' . $fileUploadLimit . 'MB'); + } + if ($name === '') { + return Base::retError('文件名不能为空'); + } + if (!in_array($scene, self::SCENES, true)) { + return Base::retError('不支持的上传场景'); + } + + // 1) 秒传:同用户已上传过同 hash 文件 → 直接复用入库 + $hit = self::trySecondPass($user, $scene, $hash, $name, $sceneParams); + if ($hit !== null) { + return Base::retSuccess('success', $hit); + } + + // 2) 续传:同用户同 hash 有未完成上传 + $reuseKey = self::keyHashIndex($user->userid, $hash); + $existingId = Redis::get($reuseKey); + if ($existingId) { + $meta = self::loadMeta($existingId); + if ($meta && $meta['userid'] === $user->userid && $meta['hash'] === $hash) { + return Base::retSuccess('success', self::sessionView($existingId, $meta)); + } + // 反查指向了已失效的 upload_id,清掉 + Redis::del($reuseKey); + } + + // 3) 新建 + $uploadId = Base::generatePassword(32); + $chunkCount = intval(ceil($size / self::CHUNK_SIZE)); + $meta = [ + 'hash' => $hash, + 'size' => $size, + 'name' => $name, + 'scene' => $scene, + 'scene_params' => $sceneParams, + 'userid' => intval($user->userid), + 'chunk_size' => self::CHUNK_SIZE, + 'chunk_count' => $chunkCount, + 'created_at' => time(), + ]; + Redis::setex(self::keyMeta($uploadId), self::STATE_TTL, json_encode($meta, JSON_UNESCAPED_UNICODE)); + Redis::setex($reuseKey, self::STATE_TTL, $uploadId); + Base::makeDir(self::chunkDir($user->userid, $uploadId)); + + return Base::retSuccess('success', self::sessionView($uploadId, $meta)); + } + + /** + * 接收一个分片。 + * + * @param User $user + * @param string $uploadId + * @param int $index 分片序号(0-based) + * @param UploadedFile|null $blob + * @return array + */ + public static function receive(User $user, string $uploadId, int $index, $blob): array + { + $meta = self::loadMeta($uploadId); + if (!$meta) { + return Base::retError('上传会话不存在或已过期'); + } + if ($meta['userid'] !== intval($user->userid)) { + return Base::retError('上传会话归属错误'); + } + if ($index < 0 || $index >= $meta['chunk_count']) { + return Base::retError('分片序号超出范围'); + } + if (!$blob || !$blob->isValid()) { + return Base::retError('分片数据无效'); + } + // 最后一片可能小于 CHUNK_SIZE,其余必须等于 + $isLast = $index === $meta['chunk_count'] - 1; + $chunkSize = $blob->getSize(); + if (!$isLast && $chunkSize !== self::CHUNK_SIZE) { + return Base::retError('分片大小不符合预期'); + } + if ($isLast) { + $expectLast = $meta['size'] - self::CHUNK_SIZE * ($meta['chunk_count'] - 1); + if ($chunkSize !== $expectLast) { + return Base::retError('末尾分片大小不符合预期'); + } + } + $dir = self::chunkDir($user->userid, $uploadId); + Base::makeDir($dir); + $blob->move($dir, (string)$index); + // 记录已收 + 续期三个相关 key + Redis::sadd(self::keyChunks($uploadId), $index); + Redis::expire(self::keyChunks($uploadId), self::STATE_TTL); + Redis::expire(self::keyMeta($uploadId), self::STATE_TTL); + Redis::expire(self::keyHashIndex($user->userid, $meta['hash']), self::STATE_TTL); + + return Base::retSuccess('success', [ + 'upload_id' => $uploadId, + 'received' => self::receivedList($uploadId), + ]); + } + + /** + * 合并分片并入库。需要在 Lock 内调用。 + * + * @param User $user + * @param string $uploadId + * @return array scene 入库返回结构(与 retSuccess/retError 对齐) + */ + public static function merge(User $user, string $uploadId): array + { + $meta = self::loadMeta($uploadId); + if (!$meta) { + return Base::retError('上传会话不存在或已过期'); + } + if ($meta['userid'] !== intval($user->userid)) { + return Base::retError('上传会话归属错误'); + } + $received = self::receivedList($uploadId); + if (count($received) !== $meta['chunk_count']) { + return Base::retError('分片不完整,无法合并'); + } + + return Lock::withLock("upload:merge:{$uploadId}", function () use ($user, $uploadId, $meta) { + $dir = self::chunkDir($user->userid, $uploadId); + $mergedPath = $dir . '/merged.' . substr($meta['hash'], 0, 8); + $writeFp = @fopen($mergedPath, 'wb'); + if (!$writeFp) { + return Base::retError('无法创建合并文件'); + } + // 拼接与 md5 同步进行:一遍磁盘读完成"写文件 + 算 hash" + $hashCtx = hash_init('md5'); + try { + for ($i = 0; $i < $meta['chunk_count']; $i++) { + $partPath = $dir . '/' . $i; + $readFp = @fopen($partPath, 'rb'); + if (!$readFp) { + return Base::retError("分片读取失败:{$i}"); + } + while (!feof($readFp)) { + $buf = fread($readFp, 1024 * 1024); + if ($buf === false) { + fclose($readFp); + return Base::retError("分片读取失败:{$i}"); + } + fwrite($writeFp, $buf); + hash_update($hashCtx, $buf); + } + fclose($readFp); + } + } finally { + fclose($writeFp); + } + $actualHash = hash_final($hashCtx); + if ($actualHash !== $meta['hash']) { + @unlink($mergedPath); + return Base::retError('文件校验失败,请重试'); + } + + // 调用 scene 入库 + $result = self::dispatch($user, $meta, $mergedPath); + + // 清理(无论成功失败都清,失败用户重新启 upload) + self::cleanup($user->userid, $uploadId, $meta['hash']); + + return $result; + }, 60000, 60000); + } + + /** + * 用户主动取消:校验归属后清理。会话不存在或归属错误一律静默成功,前端取消按钮不需要分支处理。 + */ + public static function cancelByUser(User $user, string $uploadId): void + { + $meta = self::loadMeta($uploadId); + if (!$meta || intval($meta['userid'] ?? 0) !== $user->userid) { + return; + } + self::cleanup($user->userid, $uploadId, $meta['hash'] ?? ''); + } + + /** + * 清理一个 upload_id 的所有状态。 + */ + public static function cleanup(int $userid, string $uploadId, string $hash = ''): void + { + Redis::del(self::keyMeta($uploadId)); + Redis::del(self::keyChunks($uploadId)); + if ($hash) { + Redis::del(self::keyHashIndex($userid, $hash)); + } + $dir = self::chunkDir($userid, $uploadId); + if (is_dir($dir)) { + Base::deleteDirAndFile($dir); + } + } + + // ===== scene dispatcher ===== + + /** + * 把合并后的本地文件交给对应 scene 入库。 + * 返回结构对齐各 scene 老接口的 retSuccess。 + */ + protected static function dispatch(User $user, array $meta, string $mergedPath): array + { + $scene = $meta['scene']; + $name = $meta['name']; + $hash = $meta['hash']; + $params = $meta['scene_params'] ?? []; + + switch ($scene) { + case 'file_cabinet': + $pid = intval($params['pid'] ?? 0); + $webkitRelativePath = strval($params['webkit_relative_path'] ?? $name); + $overwrite = boolval($params['overwrite'] ?? false); + // pid 锁避免与并发上传的 handleDuplicateName / 中间目录创建竞态 + try { + return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite) { + $result = (new FileModel)->contentUploadFromPath($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite); + $outName = $result['data']['name'] ?? $name; + return Base::retSuccess($outName . ' 上传成功', $result['addItem']); + }, 120000, 120000); + } catch (ApiException $e) { + return Base::retError($e->getMessage()); + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Failed to acquire lock')) { + return Base::retError('上传繁忙,请稍后再试'); + } + return Base::retError($e->getMessage()); + } + + case 'image': + // 头像 / 系统图片 / 编辑器粘贴图片,对齐 system/imgupload + $width = intval($params['width'] ?? 0); + $height = intval($params['height'] ?? 0); + $whcut = strval($params['whcut'] ?? 'percentage'); + $whcut = match ($whcut) { + '1' => 'cover', + '0' => 'contain', + 'cover', 'contain' => $whcut, + default => 'percentage', + }; + $scale = [$width ?: 2160, $height ?: 4160, $whcut]; + $imagePath = "uploads/user/picture/" . $user->userid . "/" . date("Ym") . "/"; + $data = Base::uploadFromPath([ + "path_local" => $mergedPath, + "name" => $name, + "type" => 'image', + "path" => $imagePath, + "scale" => $scale, + "quality" => true, + ]); + if (Base::isError($data)) { + return $data; + } + return Base::retSuccess('success', $data['data']); + + case 'generic_file': + // 编辑器粘贴文件 / 系统通用文件,对齐 system/fileupload + $filePath = "uploads/user/file/" . $user->userid . "/" . date("Ym") . "/"; + $data = Base::uploadFromPath([ + "path_local" => $mergedPath, + "name" => $name, + "type" => 'file', + "path" => $filePath, + "quality" => true, + ]); + return $data; + + case 'dialog_file': + // 聊天发文件 + 任务附件共用同一接入(任务附件本质是任务对话流的一条消息) + $dialogIds = $params['dialog_ids'] ?? []; + if (!is_array($dialogIds)) { + $dialogIds = [$dialogIds]; + } + $dialogIds = array_values(array_filter(array_map('intval', $dialogIds))); + if (empty($dialogIds)) { + return Base::retError('dialog_ids 不能为空'); + } + $replyId = intval($params['reply_id'] ?? 0); + $imageAttachment = boolval($params['image_attachment'] ?? false); + try { + return WebSocketDialog::sendMsgFilesFromPath($user, $dialogIds, $mergedPath, $name, $replyId, $imageAttachment); + } catch (ApiException $e) { + return Base::retError($e->getMessage()); + } + + default: + return Base::retError("scene 暂未实现:{$scene}"); + } + } + + /** + * 同 hash 命中则在目标位置复用源 FileContent 指向的物理文件,零字节传输。 + * 未命中返回 null 让上层走真上传。 + */ + protected static function trySecondPass(User $user, string $scene, string $hash, string $name, array $sceneParams): ?array + { + if ($scene !== 'file_cabinet') { + return null; + } + $hit = FileModel::whereUserid($user->userid)->whereHash($hash)->whereNull('deleted_at')->first(); + if (!$hit) { + return null; + } + $srcContent = FileContent::whereFid($hit->id)->orderByDesc('id')->first(); + if (!$srcContent) { + return null; + } + $contentArr = is_array($srcContent->content) + ? $srcContent->content + : json_decode($srcContent->content, true); + if (empty($contentArr['url'])) { + return null; + } + $rawPid = intval($sceneParams['pid'] ?? 0); + $webkitRelativePath = strval($sceneParams['webkit_relative_path'] ?? $name); + $overwrite = boolval($sceneParams['overwrite'] ?? false); + + try { + return Lock::withLock("file:upload:{$user->userid}:{$rawPid}", function () use ($user, $rawPid, $webkitRelativePath, $overwrite, $hit, $hash, $name, $contentArr) { + [$pid, $userid, $addItem] = (new FileModel)->contentUploadPrep($user, $rawPid, $webkitRelativePath); + + $ext = $hit->ext; + $bareName = Base::rightDelete($name, '.' . $ext); + $existing = null; + if ($overwrite) { + $existing = FileModel::wherePid($pid)->whereName($bareName)->whereExt($ext)->whereNull('deleted_at')->first(); + } + if ($existing) { + $existing->size = $hit->size; + $existing->hash = $hash; + $existing->type = $hit->type; + if (!$existing->saveBeforePP()) { + throw new ApiException('秒传保存失败'); + } + FileContent::createInstance([ + 'fid' => $existing->id, + 'content' => $contentArr, + 'text' => '', + 'size' => $existing->size, + 'userid' => $user->userid, + ])->save(); + $created = FileModel::find($existing->id); + $overwriteFlag = 1; + } else { + $newFile = FileModel::createInstance([ + 'pid' => $pid, + 'name' => $bareName, + 'type' => $hit->type, + 'ext' => $ext, + 'size' => $hit->size, + 'hash' => $hash, + 'userid' => $userid, + 'created_id' => $user->userid, + ]); + $newFile->handleDuplicateName(); + if (!$newFile->saveBeforePP()) { + throw new ApiException('秒传保存失败'); + } + FileContent::createInstance([ + 'fid' => $newFile->id, + 'content' => $contentArr, + 'text' => '', + 'size' => $newFile->size, + 'userid' => $user->userid, + ])->save(); + $created = FileModel::find($newFile->id); + $overwriteFlag = 0; + } + $created->pushMsg($overwriteFlag ? 'update' : 'add', $created); + $data = FileModel::handleImageUrl($created->toArray()); + $data['full_name'] = $name; + $data['overwrite'] = $overwriteFlag; + $addItem[] = $data; + return [ + 'done' => true, + 'instant' => true, + 'addItem' => $addItem, + 'msg' => $name . ' 秒传成功', + ]; + }, 120000, 120000); + } catch (\Throwable $_e) { + // 退化到真上传:错误由 dispatch 阶段权威报出,避免两条路径错误码不一致 + return null; + } + } + + // ===== helpers ===== + + protected static function keyMeta(string $uploadId): string + { + return "upload:{$uploadId}"; + } + + protected static function keyChunks(string $uploadId): string + { + return "upload:{$uploadId}:chunks"; + } + + protected static function keyHashIndex(int $userid, string $hash): string + { + return "upload:hash:{$userid}:{$hash}"; + } + + protected static function chunkDir(int $userid, string $uploadId): string + { + return public_path("uploads/tmp/chunks/{$userid}/{$uploadId}"); + } + + protected static function loadMeta(string $uploadId): ?array + { + $raw = Redis::get(self::keyMeta($uploadId)); + if (!$raw) { + return null; + } + $data = json_decode($raw, true); + return is_array($data) ? $data : null; + } + + protected static function receivedList(string $uploadId): array + { + $list = Redis::smembers(self::keyChunks($uploadId)) ?: []; + $list = array_map('intval', $list); + sort($list); + return $list; + } + + protected static function sessionView(string $uploadId, array $meta): array + { + return [ + 'done' => false, + 'upload_id' => $uploadId, + 'chunk_size' => $meta['chunk_size'], + 'chunk_count' => $meta['chunk_count'], + 'received' => self::receivedList($uploadId), + ]; + } +} diff --git a/app/Tasks/DeleteTmpTask.php b/app/Tasks/DeleteTmpTask.php index e3ed98680..7095a84da 100644 --- a/app/Tasks/DeleteTmpTask.php +++ b/app/Tasks/DeleteTmpTask.php @@ -94,6 +94,27 @@ class DeleteTmpTask extends AbstractTask } break; + case 'tmp_chunks': + // 分片上传残留:upload_id 目录超过 hours 小时未合并则整目录清掉 + $chunksRoot = public_path('uploads/tmp/chunks'); + if (!is_dir($chunksRoot)) { + break; + } + $cutoff = time() - 3600 * $this->hours; + foreach (glob($chunksRoot . '/*', GLOB_ONLYDIR) ?: [] as $userDir) { + foreach (glob($userDir . '/*', GLOB_ONLYDIR) ?: [] as $uploadDir) { + $mtime = @filemtime($uploadDir); + if ($mtime && $mtime < $cutoff) { + Base::deleteDirAndFile($uploadDir); + } + } + // 顺手清理空 user 目录 + if (count(scandir($userDir) ?: []) <= 2) { + @rmdir($userDir); + } + } + break; + case 'user_device': UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours)) ->orderBy('id') diff --git a/database/migrations/2026_06_30_001026_add_hash_to_files_table.php b/database/migrations/2026_06_30_001026_add_hash_to_files_table.php new file mode 100644 index 000000000..8b590553c --- /dev/null +++ b/database/migrations/2026_06_30_001026_add_hash_to_files_table.php @@ -0,0 +1,24 @@ +char('hash', 32)->nullable()->after('size')->comment('文件内容 md5(分片上传秒传用)'); + $table->index('hash', 'files_hash_index'); + }); + } + + public function down() + { + Schema::table('files', function (Blueprint $table) { + $table->dropIndex('files_hash_index'); + $table->dropColumn('hash'); + }); + } +}; diff --git a/language/original-api.txt b/language/original-api.txt index 00389170f..dcf5577ec 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -1021,3 +1021,22 @@ AI 助手 密钥无效 应用未安装 菜单不存在 +源文件不存在 +文件 hash 格式错误 +文件大小无效 +文件超过系统支持的最大尺寸 +文件名不能为空 +不支持的上传场景 +上传会话不存在或已过期 +上传会话归属错误 +分片序号超出范围 +分片数据无效 +分片大小不符合预期 +末尾分片大小不符合预期 +分片读取失败:(*) +分片不完整,无法合并 +无法创建合并文件 +文件校验失败,请重试 +scene 暂未实现:(*) +upload_id 不能为空 +合并繁忙,请稍后再试 diff --git a/language/original-web.txt b/language/original-web.txt index 8b984b0de..b219eda0c 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -1434,6 +1434,16 @@ License Key 私聊禁言 群聊禁言 默认不限制 +默认 1G +准备中... +上传中... +合并中... +上传已暂停 +网络异常,重试中... +上传初始化失败 +分片上传失败 +合并失败 +分片重试耗尽 开放:所有人都可以在全员群组发言。 开放:所有人都可以相互发起个人聊天。 开放:允许个人群组聊天发言。 diff --git a/package.json b/package.json index ec7dd8101..e69a8b78c 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "resolve-url-loader": "^4.0.0", "sass": "1.77.4", "sass-loader": "14.2.1", + "spark-md5": "^3.0.2", "stylus": "^0.59.0", "stylus-loader": "^7.1.0", "tinymce": "^5.10.3", diff --git a/resources/ai-kb/zh/faq/common-faq/upload-size-limit.md b/resources/ai-kb/zh/faq/common-faq/upload-size-limit.md index a34ca893c..e09098b21 100644 --- a/resources/ai-kb/zh/faq/common-faq/upload-size-limit.md +++ b/resources/ai-kb/zh/faq/common-faq/upload-size-limit.md @@ -17,9 +17,9 @@ related_pages: [file_upload, dialog_send_file] prerequisites: [] negative: - 单个上传请求受 PHP / Nginx 双层限制,前端的「最大尺寸」提示只是友好封装 - - 没有「拆包重传」按钮,超大文件需要事先分块或直接上传到外部存储 - - 同一文件多次重传不会绕过大小限制 -last_verified: v1.7.90 + - ≥10MB 文件已自动走分片上传,不需要手动拆包;超过系统设置上限时由应用层拒绝 + - 同一 hash 文件可秒传命中,但仍受系统设置的"单文件上限"约束 +last_verified: v1.8.45 --- # 文件超大上传失败 @@ -37,14 +37,15 @@ DooTask 文件上传受三层限制: 任何一层超出都会拒收。出错信息以最先拒绝的那层为准——浏览器层先校验则提示「超出文件大小限制」;后端先校验则提示 413。 ## 解决 -1. 直接拖文件到「文件中心」走文件上传流程,限制最大(默认 1024M) -2. 大于上限时拆分:用 zip 分卷、或上传到云盘后分享外链 -3. 聊天里发大文件不要走「图片」入口(小限),用「发送文件」按钮(大限) -4. 管理员可调高限制:改 `docker/php/php.ini` 的 `upload_max_filesize` 和 `post_max_size`,同时改 nginx 的 `client_max_body_size`,重启容器生效 +1. 默认 1G 内的文件应直接成功;≥10MB 自动走分片上传(聊天/文件柜/任务附件/编辑器/头像统一) +2. 同一 hash 文件秒传命中:上传过的视频再次发,瞬间出现在目标位置,零等待 +3. 中途断网或刷新页面,重新选同一文件会跳过已上传分片继续传 +4. 管理员需要更大上限:管理后台「系统设置」→「文件上传限制」填具体 MB 数(如 `5120`=5G),即可突破默认 1G +5. 仅当填的值超过 PHP/Nginx/Swoole 底层上限时才需要改 `docker/php/php.ini` 等部署配置(默认底层均为 1G,因为单分片只有 5MB 所以分片路径不受底层约束) ## 不支持 -- 客户端不会自动分片重传超过 `post_max_size` 的文件 -- 主程序未内置 chunked upload / 断点续传 API(除部分插件场景) +- 跨设备 / 跨浏览器续传(续传索引保存在 localStorage,仅本机本浏览器内有效) +- 完全无认证场景下分片(必须登录) - 修改 php.ini 后必须容器重启,热加载不生效 [[file.upload.howto]] / [[system-setting.file.howto]] 给管理员看 diff --git a/resources/ai-kb/zh/howto/file/upload.md b/resources/ai-kb/zh/howto/file/upload.md index d22575d73..d1c9ceb50 100644 --- a/resources/ai-kb/zh/howto/file/upload.md +++ b/resources/ai-kb/zh/howto/file/upload.md @@ -19,8 +19,8 @@ negative: - 同一用户在同一目录下并发上传会自动排队(避免数据库死锁),慢但不会丢 - 单个文件夹直接子项上限 300,超出会报错 - 不支持上传文件夹后保留文件夹下原有空目录(空目录会被忽略) - - 不支持断点续传,超大文件请先压缩 -last_verified: v1.7.90 + - 跨设备续传不可用:续传索引存浏览器 localStorage,仅本机本浏览器内有效 +last_verified: v1.8.45 --- # 上传文件 @@ -47,7 +47,14 @@ last_verified: v1.7.90 - 父目录:当前所在文件夹 - 版本:上传产生一条新的 FileContent 记录(详见 [[file.version.concept]]) +## 大文件 / 断点续传 / 秒传 +- **≥10MB 自动分片**:文件切成 5MB 分片,3 路并发上传;前端先在 Web Worker 算 md5(不卡 UI) +- **断点续传**:上传中刷新页面或断网后,重新选同一文件会跳过已上传分片,从中断处继续 +- **秒传**:同用户已上传过同 hash 文件(命中 `files.hash`),目标文件夹立刻出现新记录,零传输 +- 状态保存:服务端 Redis `upload:*` key TTL 24h;浏览器 localStorage `chunked_upload:` 索引同样 24h +- 残留磁盘自动清理:`uploads/tmp/chunks/{userid}/{upload_id}/` 超过 24h 由 `DeleteTmpTask` 兜底清除 + ## 不支持 - 无法向直接子项 ≥ 300 的文件夹继续上传(会被拒绝) -- 不支持断点续传 / 分片上传(超大文件建议先压缩) +- 跨设备 / 跨浏览器续传(localStorage 索引仅本机有效;服务端按 hash+userid 反查可兜底) - 不支持移动端后台续传(应用切到后台可能中断) diff --git a/resources/ai-kb/zh/howto/system-setting/file-upload-limit.md b/resources/ai-kb/zh/howto/system-setting/file-upload-limit.md index 371665e1f..0f69cbdb1 100644 --- a/resources/ai-kb/zh/howto/system-setting/file-upload-limit.md +++ b/resources/ai-kb/zh/howto/system-setting/file-upload-limit.md @@ -19,9 +19,9 @@ related_tools: [] related_pages: [] negative: - 该限制只控制单个文件大小,不是磁盘配额或总量上限 - - 留空 = 不限制;后端仍受 PHP / Nginx 层 client_max_body_size 等服务级限制 + - 留空 = 默认 1G(前后端兜底一致);填具体值后取该值;仍受 PHP / Nginx 层服务级限制 - 不能按用户 / 部门设差异化阈值,全局生效 -last_verified: v1.7.90 +last_verified: v1.8.45 --- # 单文件上传大小限制 @@ -29,23 +29,28 @@ last_verified: v1.7.90 ## 入口 桌面端:左上角头像 →「系统设置」→「系统设置」标签 →「其他设置」→「文件上传限制」。 -字段名:`file_upload_limit`,整数,单位 **MB**,默认留空(= 不限制)。 +字段名:`file_upload_limit`,整数,单位 **MB**,默认留空(前端 placeholder 显示「默认 1G」)。 ## 生效范围 -后端 `Base::uploadFile` 在每次接收上传时都会读取该值,作用于: +后端 `Base::upload` / `Base::uploadFromPath` 在每次接收上传时都会读取该值,作用于: - **聊天消息中发送的文件 / 图片附件** - **任务详情里的附件上传** - **「文件」应用中的文档上传** -- 各种自定义上传入口(凡是走 `Base::uploadFile` 的接口) +- 各种自定义上传入口(凡是走 `Base::upload` 的接口) -逻辑:调用方未显式传 `size` 参数时,取 `file_upload_limit * 1024 KB` 作为单文件上限。超过则报错 `文件大小超限,最大限制:N KB`。 +逻辑:调用方未显式传 `size` 参数时,先取 `file_upload_limit`;为空则按 **1024 MB(1G)兜底**;超过则报错 `文件大小超限,最大限制:N KB`。 + +## 与分片上传的关系 +- 前端 ≥ 10MB 自动走分片上传(单分片 5MB),跟系统设置的"单文件上限"是两件事 +- 提高 `file_upload_limit` 后,分片上传可以突破老的 1G 限制(每个分片远小于底层 PHP/Nginx/Swoole 限制) +- 例如填 `5120`(5G)后,5G 视频可通过分片上传完成(受磁盘 / 内存等部署能力约束) ## 字段默认值 | 字段 | 默认 | 单位 | |---|---|---| -| `file_upload_limit` | 空(不限制) | MB | +| `file_upload_limit` | 空(按 1G 兜底) | MB | ## 操作步骤 1. 进入「系统设置」→「系统设置」→「其他设置」 diff --git a/resources/assets/js/components/ImgUpload.vue b/resources/assets/js/components/ImgUpload.vue index 0a83b6c50..aea97fe54 100755 --- a/resources/assets/js/components/ImgUpload.vue +++ b/resources/assets/js/components/ImgUpload.vue @@ -77,6 +77,8 @@ diff --git a/resources/assets/js/pages/manage/components/DialogUpload.vue b/resources/assets/js/pages/manage/components/DialogUpload.vue index b2ed3a0ab..5a049cc9f 100644 --- a/resources/assets/js/pages/manage/components/DialogUpload.vue +++ b/resources/assets/js/pages/manage/components/DialogUpload.vue @@ -19,6 +19,7 @@