From baef2fb64cb60112021ee004ff73d795d98fbcb7 Mon Sep 17 00:00:00 2001 From: wushumin Date: Fri, 22 May 2026 15:47:23 +0800 Subject: [PATCH] chore: sync release updates --- admin-web/src/api/admin.ts | 89 ++++-- admin-web/src/pages/appraisal-tasks/index.vue | 10 +- admin-web/src/pages/reports/index.vue | 14 + .../src/pages/warehouse-workbench/index.vue | 56 +++- .../admin/AppraisalTasksController.php | 8 +- .../controller/admin/FileUploadController.php | 24 ++ .../admin/SystemConfigsController.php | 26 +- .../admin/WarehouseWorkbenchController.php | 4 +- .../app/controller/app/ReportsController.php | 101 +++++-- .../app/middleware/AdminAuthMiddleware.php | 1 + .../app/support/AppraisalEvidenceService.php | 65 +---- .../app/support/FileStorageConfigService.php | 23 ++ server-api/app/support/FileUploadService.php | 270 ++++++++++++++++++ server-api/config/route.php | 2 + server-api/config/server.php | 7 +- user-app/src/pages/report/detail.vue | 2 +- work-app/src/api/admin.ts | 80 +++++- work-app/src/pages/order/detail.vue | 81 +++++- work-app/src/pages/report/detail.vue | 5 + work-app/src/pages/return-shipping/index.vue | 9 +- work-app/src/pages/scan/index.vue | 9 +- work-app/src/pages/task/detail.vue | 89 +++++- work-app/src/utils/request.ts | 35 +++ 23 files changed, 879 insertions(+), 131 deletions(-) create mode 100644 server-api/app/controller/admin/FileUploadController.php create mode 100644 server-api/app/support/FileUploadService.php diff --git a/admin-web/src/api/admin.ts b/admin-web/src/api/admin.ts index d04b1d1..d520a00 100644 --- a/admin-web/src/api/admin.ts +++ b/admin-web/src/api/admin.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import request from "./request"; import type { AdminSessionInfo } from "../utils/auth"; @@ -21,6 +22,59 @@ export interface AdminFileAsset { mime_type?: string; } +export type AdminUploadScene = "appraisal_evidence" | "zhongjian_report" | "warehouse_inbound_evidence" | "warehouse_return_packing"; + +export interface AdminDirectUploadPolicy { + enabled: boolean; + upload_url?: string; + form_data?: Record; + asset?: AdminFileAsset; + max_size?: number; + max_size_text?: string; + expires_at?: string; +} + +async function uploadFileToOss(uploadUrl: string, formData: Record, file: File) { + const ossFormData = new FormData(); + Object.entries(formData).forEach(([key, value]) => { + ossFormData.append(key, String(value)); + }); + ossFormData.append("file", file); + + await axios.post(uploadUrl, ossFormData, { timeout: 300000 }); +} + +async function uploadManagedAdminFile(file: File, scene: AdminUploadScene, fallbackUrl: string, fallbackFields: Record = {}) { + const policyResponse = await request.post("/api/admin/file-upload/direct-policy", { + upload_scene: scene, + original_name: file.name, + file_size: file.size, + mime_type: file.type, + }) as { code: number; message: string; data: AdminDirectUploadPolicy }; + + const policy = policyResponse.data; + if (!policy.enabled) { + const formData = new FormData(); + formData.append("file", file); + formData.append("upload_scene", scene); + Object.entries(fallbackFields).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + return request.post(fallbackUrl, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) as Promise<{ code: number; message: string; data: AdminFileAsset }>; + } + + if (!policy.upload_url || !policy.form_data || !policy.asset) { + throw new Error("OSS 上传签名无效,请稍后重试"); + } + + await uploadFileToOss(policy.upload_url, policy.form_data, file); + return { code: 0, message: "ok", data: policy.asset }; +} + export interface AdminTransferFlowSummary { id: number; internal_tag_no: string; @@ -1800,24 +1854,12 @@ export const adminApi = { }; }>; }, - uploadAppraisalEvidenceFile(file: File) { - const formData = new FormData(); - formData.append("file", file); - return request.post("/api/admin/appraisal-task/evidence/upload", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }) as Promise<{ + uploadAppraisalEvidenceFile(file: File, scene: AdminUploadScene = "appraisal_evidence", taskId?: number) { + const fallbackFields: Record = taskId ? { task_id: taskId } : {}; + return uploadManagedAdminFile(file, scene, "/api/admin/appraisal-task/evidence/upload", fallbackFields) as Promise<{ code: number; message: string; - data: { - file_id: string; - file_url: string; - thumbnail_url: string; - name?: string; - file_type?: string; - mime_type?: string; - }; + data: AdminFileAsset; }>; }, deleteAppraisalEvidenceFile(fileUrl: string) { @@ -1888,14 +1930,15 @@ export const adminApi = { internal_tag_no: internalTagNo, }) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>; }, + uploadWarehouseInboundEvidenceFile(file: File) { + return uploadManagedAdminFile(file, "warehouse_inbound_evidence", "/api/admin/warehouse-workbench/inbound/evidence/upload") as Promise<{ + code: number; + message: string; + data: AdminFileAsset; + }>; + }, uploadWarehouseReturnPackingFile(file: File) { - const formData = new FormData(); - formData.append("file", file); - return request.post("/api/admin/warehouse-workbench/return/packing/upload", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }) as Promise<{ + return uploadManagedAdminFile(file, "warehouse_return_packing", "/api/admin/warehouse-workbench/return/packing/upload") as Promise<{ code: number; message: string; data: AdminFileAsset; diff --git a/admin-web/src/pages/appraisal-tasks/index.vue b/admin-web/src/pages/appraisal-tasks/index.vue index f96986a..98bf0d5 100644 --- a/admin-web/src/pages/appraisal-tasks/index.vue +++ b/admin-web/src/pages/appraisal-tasks/index.vue @@ -579,6 +579,9 @@ function triggerEvidenceUpload() { } async function handleEvidenceFileSelect(event: Event) { + if (!detail.value) { + return; + } const target = event.target as HTMLInputElement; const files = Array.from(target.files || []); if (!files.length) { @@ -588,7 +591,7 @@ async function handleEvidenceFileSelect(event: Event) { evidenceUploading.value = true; try { for (const file of files) { - const response = await adminApi.uploadAppraisalEvidenceFile(file); + const response = await adminApi.uploadAppraisalEvidenceFile(file, "appraisal_evidence", detail.value.task_info.id); resultAttachments.value.push(response.data); } ElMessage.success("附件上传成功"); @@ -609,6 +612,9 @@ function triggerZhongjianReportUpload() { } async function handleZhongjianReportFileSelect(event: Event) { + if (!detail.value) { + return; + } const target = event.target as HTMLInputElement; const files = Array.from(target.files || []); if (!files.length) { @@ -618,7 +624,7 @@ async function handleZhongjianReportFileSelect(event: Event) { zhongjianReportUploading.value = true; try { for (const file of files) { - const response = await adminApi.uploadAppraisalEvidenceFile(file); + const response = await adminApi.uploadAppraisalEvidenceFile(file, "zhongjian_report", detail.value.task_info.id); zhongjianReportFiles.value.push(response.data); } ElMessage.success("中检报告文件已上传"); diff --git a/admin-web/src/pages/reports/index.vue b/admin-web/src/pages/reports/index.vue index 874ad9f..e4da921 100644 --- a/admin-web/src/pages/reports/index.vue +++ b/admin-web/src/pages/reports/index.vue @@ -596,6 +596,16 @@ watch(
说明
{{ detail.result_info.result_desc || "-" }}
+ +
+
对外备注
+
{{ detail.result_info.external_remark }}
+
@@ -634,6 +644,10 @@ watch(
成色评级
{{ detail.valuation_info.condition_grade || "-" }}
+
+
成色说明
+
{{ detail.valuation_info.condition_desc || "-" }}
+
估值区间
¥{{ detail.valuation_info.valuation_min || 0 }} - ¥{{ detail.valuation_info.valuation_max || 0 }}
diff --git a/admin-web/src/pages/warehouse-workbench/index.vue b/admin-web/src/pages/warehouse-workbench/index.vue index a532979..e646ebf 100644 --- a/admin-web/src/pages/warehouse-workbench/index.vue +++ b/admin-web/src/pages/warehouse-workbench/index.vue @@ -20,6 +20,7 @@ const returnTagNo = ref(""); const returnMaterialQr = ref(""); const returnExpressCompany = ref(""); const returnTrackingNo = ref(""); +const inboundAttachments = ref([]); const returnPackingAttachments = ref([]); const inboundContext = ref(null); @@ -34,6 +35,7 @@ const returnTrackingInputRef = ref(null); const returnReviewDrawerVisible = ref(false); const returnReviewLoading = ref(false); const returnConfirmLoading = ref(false); +const inboundUploading = ref(false); const returnPackingUploading = ref(false); const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian"); @@ -157,6 +159,7 @@ async function lookupInbound() { } loading.value = true; try { + inboundAttachments.value = []; const response = await adminApi.lookupWarehouseInbound(trackingNo); inboundContext.value = response.data; ElMessage.success("已匹配订单"); @@ -179,13 +182,19 @@ async function receiveInbound() { ElMessage.warning("请扫描内部流转挂牌"); return; } + if (inboundUploading.value) { + ElMessage.warning("入库附件上传中,请稍后提交"); + return; + } actionLoading.value = true; try { const response = await adminApi.receiveWarehouseInbound({ inbound_no: inboundTrackingNo.value.trim(), internal_tag_no: inboundTagNo.value.trim(), + inbound_attachments: inboundAttachments.value, }); inboundContext.value = response.data; + inboundAttachments.value = []; ElMessage.success("入库完成"); } catch (error: any) { ElMessage.error(error?.message || "入库失败"); @@ -194,6 +203,28 @@ async function receiveInbound() { } } +async function uploadInboundAttachment(options: { file: File }) { + inboundUploading.value = true; + try { + const response = await adminApi.uploadWarehouseInboundEvidenceFile(options.file); + if (response.code !== 0) { + ElMessage.error(response.message || "入库附件上传失败"); + return; + } + inboundAttachments.value.push(response.data); + ElMessage.success("入库附件已上传"); + } catch (error: any) { + console.error(error); + ElMessage.error(error?.message || "入库附件上传失败"); + } finally { + inboundUploading.value = false; + } +} + +function removeInboundAttachment(fileUrl: string) { + inboundAttachments.value = inboundAttachments.value.filter((item) => item.file_url !== fileUrl); +} + async function lookupZhongjian() { if (!zhongjianTagNo.value.trim()) { ElMessage.warning("请扫描内部流转码"); @@ -442,7 +473,30 @@ function openFile(url: string) {
匹配订单 - 绑定挂牌并入库 + 绑定挂牌并入库 +
+
+
+ + 上传拆包图片/视频 + + {{ inboundAttachments.length }} 个入库附件 +
+
+
+ + {{ fileTypeText(file) }} + 移除 +
+
diff --git a/server-api/app/controller/admin/AppraisalTasksController.php b/server-api/app/controller/admin/AppraisalTasksController.php index dfae45b..f66edb4 100644 --- a/server-api/app/controller/admin/AppraisalTasksController.php +++ b/server-api/app/controller/admin/AppraisalTasksController.php @@ -1045,7 +1045,11 @@ class AppraisalTasksController } try { - $asset = $this->evidenceService()->upload($request); + $scene = (string)$request->input('upload_scene', 'appraisal_evidence'); + if (!in_array($scene, ['appraisal_evidence', 'zhongjian_report'], true)) { + $scene = 'appraisal_evidence'; + } + $asset = $this->evidenceService()->upload($request, 'file', $scene); return api_success($asset); } catch (\Throwable $e) { return api_error($e->getMessage(), 422); @@ -1859,11 +1863,13 @@ class AppraisalTasksController 'brand_name' => $product['brand_name'] ?? '', 'color' => $product['color'] ?? '', 'size_spec' => $product['size_spec'] ?? '', + 'serial_no' => $product['serial_no'] ?? '', ], JSON_UNESCAPED_UNICODE), 'result_snapshot_json' => json_encode([ 'result_status' => $resultPayload['result_status'], 'result_text' => $resultPayload['result_text'], 'result_desc' => $resultPayload['result_desc'], + 'external_remark' => $resultPayload['external_remark'] ?? '', 'key_points' => $this->loadLatestOrderKeyPoints($orderId), ], JSON_UNESCAPED_UNICODE), 'appraisal_snapshot_json' => json_encode($appraisalSnapshot, JSON_UNESCAPED_UNICODE), diff --git a/server-api/app/controller/admin/FileUploadController.php b/server-api/app/controller/admin/FileUploadController.php new file mode 100644 index 0000000..b9201b9 --- /dev/null +++ b/server-api/app/controller/admin/FileUploadController.php @@ -0,0 +1,24 @@ +createOssDirectUploadPolicy( + $request, + (string)$request->input('upload_scene', ''), + (string)$request->input('original_name', ''), + (int)$request->input('file_size', 0), + (string)$request->input('mime_type', '') + )); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 422); + } + } +} diff --git a/server-api/app/controller/admin/SystemConfigsController.php b/server-api/app/controller/admin/SystemConfigsController.php index 66d4b60..8e412b1 100644 --- a/server-api/app/controller/admin/SystemConfigsController.php +++ b/server-api/app/controller/admin/SystemConfigsController.php @@ -275,7 +275,16 @@ class SystemConfigsController 'title' => 'OSS Endpoint', 'field_type' => 'text', 'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com', - 'remark' => '填写 Bucket 所在地域的公网 Endpoint。', + 'remark' => '后台服务端 SDK 使用的 Endpoint。可填公网 Endpoint;如服务器在同地域内网,也可填内网 Endpoint。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], + [ + 'config_key' => 'oss_upload_endpoint', + 'title' => 'OSS 直传 Endpoint', + 'field_type' => 'text', + 'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com', + 'remark' => '前端直传 OSS 使用的公网 Endpoint。为空时沿用 OSS Endpoint;如 OSS Endpoint 填了内网地址,这里必须填写公网地址。', 'is_secret' => false, 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], ], @@ -324,6 +333,16 @@ class SystemConfigsController 'is_secret' => false, 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], ], + [ + 'config_key' => 'direct_upload_max_size_mb', + 'title' => '直传文件大小上限 MB', + 'field_type' => 'text', + 'placeholder' => '默认 200', + 'remark' => '前端直传 OSS 的单文件最大大小,单位 MB。建议按业务网络环境设置,允许范围 1-2048。', + 'is_secret' => false, + 'default_value' => '200', + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], [ 'config_key' => 'qiniu_bucket', 'title' => '七牛 Bucket', @@ -452,6 +471,11 @@ class SystemConfigsController } } + $directUploadMaxSizeMb = trim((string)($configValueMap['file_storage.direct_upload_max_size_mb'] ?? '200')); + if ($directUploadMaxSizeMb !== '' && (!ctype_digit($directUploadMaxSizeMb) || (int)$directUploadMaxSizeMb < 1 || (int)$directUploadMaxSizeMb > 2048)) { + throw new \RuntimeException('直传文件大小上限需填写 1-2048 之间的整数'); + } + return; } diff --git a/server-api/app/controller/admin/WarehouseWorkbenchController.php b/server-api/app/controller/admin/WarehouseWorkbenchController.php index 10f9e42..8a0ccb5 100644 --- a/server-api/app/controller/admin/WarehouseWorkbenchController.php +++ b/server-api/app/controller/admin/WarehouseWorkbenchController.php @@ -43,7 +43,7 @@ class WarehouseWorkbenchController { $evidenceService = new AppraisalEvidenceService(); try { - $asset = $evidenceService->upload($request); + $asset = $evidenceService->upload($request, 'file', 'warehouse_inbound_evidence'); if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) { $evidenceService->delete((string)($asset['file_url'] ?? '')); return api_error('拆包附件仅支持上传图片或视频', 422); @@ -59,7 +59,7 @@ class WarehouseWorkbenchController { $evidenceService = new AppraisalEvidenceService(); try { - $asset = $evidenceService->upload($request); + $asset = $evidenceService->upload($request, 'file', 'warehouse_return_packing'); if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) { $evidenceService->delete((string)($asset['file_url'] ?? '')); return api_error('打包装箱附件仅支持上传图片或视频', 422); diff --git a/server-api/app/controller/app/ReportsController.php b/server-api/app/controller/app/ReportsController.php index 7f8db89..06dee88 100644 --- a/server-api/app/controller/app/ReportsController.php +++ b/server-api/app/controller/app/ReportsController.php @@ -104,7 +104,7 @@ class ReportsController 'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null), 'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice, ]; - $productDisplay = $this->buildProductDisplay($reportData, $payload['product_snapshot'], $payload['result_snapshot']); + $productDisplay = $this->buildProductDisplay($reportData, $payload['product_snapshot'], $payload['result_snapshot'], $payload['valuation_snapshot']); $reportMedia = [ 'images' => $this->filterAssetsByType($evidenceAttachments, 'image'), ]; @@ -261,7 +261,7 @@ class ReportsController ->find(); $publishTime = (string)($report['publish_time'] ?: date('Y-m-d H:i:s')); $relativeDir = 'uploads/reports/' . date('Ymd', strtotime($publishTime)); - $filename = $report['report_no'] . '-v2.pdf'; + $filename = $report['report_no'] . '-v3.pdf'; $relativePath = $relativeDir . '/' . $filename; if ($existingFile && !empty($existingFile['file_url'])) { @@ -317,20 +317,26 @@ class ReportsController return $this->storage()->publicUrl($request, $relativePath); } - private function buildProductDisplay(array $report, array $productInfo, array $resultInfo): array + private function buildProductDisplay(array $report, array $productInfo, array $resultInfo, array $valuationInfo = []): array { - $items = [ - [ - 'label' => '检测结论', - 'value' => $this->textValue($resultInfo['result_text'] ?? '') ?: '-', - 'remark' => $this->textValue($resultInfo['result_desc'] ?? ''), - ], - [ - 'label' => '品牌', - 'value' => $this->textValue($productInfo['brand_name'] ?? '') ?: '-', - 'remark' => '', - ], - ]; + $items = []; + $this->appendDisplayItem( + $items, + '检测结论', + $this->textValue($resultInfo['result_text'] ?? '') ?: '-', + $this->textValue($resultInfo['result_desc'] ?? ''), + true + ); + + foreach ([ + '品类' => $productInfo['category_name'] ?? '', + '品牌' => $productInfo['brand_name'] ?? '', + '颜色' => $productInfo['color'] ?? '', + '规格/尺寸' => $productInfo['size_spec'] ?? '', + '序列号/编码' => $productInfo['serial_no'] ?? '', + ] as $label => $value) { + $this->appendDisplayItem($items, $label, $value); + } foreach (($resultInfo['key_points'] ?? []) as $point) { if (!is_array($point)) { @@ -340,11 +346,30 @@ class ReportsController if ($label === '') { continue; } - $items[] = [ - 'label' => $label, - 'value' => $this->textValue($point['point_value'] ?? '') ?: '-', - 'remark' => $this->textValue($point['point_remark'] ?? ''), - ]; + $this->appendDisplayItem( + $items, + $label, + $this->textValue($point['point_value'] ?? '') ?: '-', + $this->textValue($point['point_remark'] ?? ''), + true + ); + } + + $conditionGrade = $this->textValue($valuationInfo['condition_grade'] ?? ''); + $conditionDesc = $this->textValue($valuationInfo['condition_desc'] ?? ''); + if ($conditionGrade !== '' || $conditionDesc !== '') { + $this->appendDisplayItem($items, '成色评级', $conditionGrade ?: '-', $conditionDesc, true); + } + + $valuationRange = $this->formatValuationRange($valuationInfo['valuation_min'] ?? 0, $valuationInfo['valuation_max'] ?? 0); + $valuationDesc = $this->textValue($valuationInfo['valuation_desc'] ?? ''); + if ($valuationRange !== '' || $valuationDesc !== '') { + $this->appendDisplayItem($items, '估值区间', $valuationRange ?: '-', $valuationDesc, true); + } + + $externalRemark = $this->textValue($resultInfo['external_remark'] ?? ''); + if ($externalRemark !== '') { + $this->appendDisplayItem($items, '备注', $externalRemark); } return [ @@ -354,6 +379,42 @@ class ReportsController ]; } + private function appendDisplayItem(array &$items, string $label, mixed $value, mixed $remark = '', bool $keepEmpty = false): void + { + $valueText = $this->textValue($value); + $remarkText = $this->textValue($remark); + if (!$keepEmpty && $valueText === '' && $remarkText === '') { + return; + } + + $items[] = [ + 'label' => $label, + 'value' => $valueText !== '' ? $valueText : '-', + 'remark' => $remarkText, + ]; + } + + private function formatValuationRange(mixed $min, mixed $max): string + { + $minValue = (float)($min ?? 0); + $maxValue = (float)($max ?? 0); + if ($minValue <= 0 && $maxValue <= 0) { + return ''; + } + if ($minValue > 0 && $maxValue > 0) { + return '¥' . $this->formatMoney($minValue) . ' - ¥' . $this->formatMoney($maxValue); + } + if ($minValue > 0) { + return '¥' . $this->formatMoney($minValue) . ' 起'; + } + return '¥' . $this->formatMoney($maxValue) . ' 内'; + } + + private function formatMoney(float $value): string + { + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } + private function buildTraceInfo(int $orderId, array $appraisalInfo, array $evidenceAttachments, Request $request): array { $logs = $orderId > 0 diff --git a/server-api/app/middleware/AdminAuthMiddleware.php b/server-api/app/middleware/AdminAuthMiddleware.php index 542ea9e..82867dc 100644 --- a/server-api/app/middleware/AdminAuthMiddleware.php +++ b/server-api/app/middleware/AdminAuthMiddleware.php @@ -52,6 +52,7 @@ class AdminAuthMiddleware implements MiddlewareInterface { return match (true) { str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'], + str_starts_with($path, '/api/admin/file-upload/') => ['warehouse_workbench.manage', 'appraisal_tasks.manage', 'orders.manage'], str_starts_with($path, '/api/admin/manual-order/') => ['orders.manage', 'warehouse_workbench.manage'], str_starts_with($path, '/api/admin/orders') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'], str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'], diff --git a/server-api/app/support/AppraisalEvidenceService.php b/server-api/app/support/AppraisalEvidenceService.php index a28a35a..6574575 100644 --- a/server-api/app/support/AppraisalEvidenceService.php +++ b/server-api/app/support/AppraisalEvidenceService.php @@ -3,54 +3,20 @@ namespace app\support; use support\Request; +use function pathinfo; use function parse_url; -use function str_starts_with; use function strtolower; class AppraisalEvidenceService { - private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'heic']; - private const VIDEO_EXTENSIONS = ['mp4', 'mov', 'm4v', 'webm', 'avi', 'mpeg', 'mpg']; - private const PDF_EXTENSIONS = ['pdf']; - - public function upload(Request $request, string $inputName = 'file'): array + public function upload(Request $request, string $inputName = 'file', string $scene = 'appraisal_evidence'): array { - $file = $request->file($inputName); - if (!$file || !$file->isValid()) { - throw new \RuntimeException('上传文件无效'); - } - - $extension = strtolower($file->getUploadExtension() ?: ''); - $fileType = $this->detectFileType($extension); - if ($fileType === 'file') { - throw new \RuntimeException('仅支持上传图片、视频或 PDF 文件'); - } - - $filename = sprintf('evidence_%s.%s', uniqid(), $extension ?: 'dat'); - $relativeDir = 'uploads/appraisal-evidence/' . date('Ymd'); - $relativePath = $relativeDir . '/' . $filename; - $this->storage()->putUploadedFile($file, $relativePath); - - $fileUrl = $this->storage()->publicUrl($request, $relativePath); - - return [ - 'file_id' => md5($relativePath), - 'file_url' => $fileUrl, - 'thumbnail_url' => $fileType === 'image' ? $fileUrl : '', - 'name' => $file->getUploadName(), - 'file_type' => $fileType, - 'mime_type' => $this->mimeType($fileType, $extension), - ]; + return $this->fileUploadService()->upload($request, $scene, $inputName); } public function delete(string $fileUrl): void { - $relativePath = $this->storage()->storagePath($fileUrl); - if (!str_starts_with($relativePath, 'uploads/appraisal-evidence/')) { - return; - } - - $this->storage()->delete($relativePath); + $this->fileUploadService()->delete($fileUrl); } public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array @@ -112,30 +78,21 @@ class AppraisalEvidenceService public function detectFileType(string $extension): string { - if (in_array($extension, self::IMAGE_EXTENSIONS, true)) { - return 'image'; - } - if (in_array($extension, self::VIDEO_EXTENSIONS, true)) { - return 'video'; - } - if (in_array($extension, self::PDF_EXTENSIONS, true)) { - return 'pdf'; - } - return 'file'; + return $this->fileUploadService()->detectFileType($extension); } private function mimeType(string $fileType, string $extension): string { - return match ($fileType) { - 'image' => 'image/' . ($extension === 'jpg' ? 'jpeg' : ($extension ?: 'jpeg')), - 'video' => 'video/' . ($extension ?: 'mp4'), - 'pdf' => 'application/pdf', - default => 'application/octet-stream', - }; + return $this->fileUploadService()->mimeType($fileType, $extension); } private function storage(): FileStorageService { return new FileStorageService(); } + + private function fileUploadService(): FileUploadService + { + return new FileUploadService(); + } } diff --git a/server-api/app/support/FileStorageConfigService.php b/server-api/app/support/FileStorageConfigService.php index 7f82f4b..1d52ead 100644 --- a/server-api/app/support/FileStorageConfigService.php +++ b/server-api/app/support/FileStorageConfigService.php @@ -18,11 +18,13 @@ class FileStorageConfigService 'driver' => $this->normalizeDriver((string)($rows['driver'] ?? 'local')), 'public_base_url' => trim((string)($rows['public_base_url'] ?? '')), 'oss_endpoint' => trim((string)($rows['oss_endpoint'] ?? '')), + 'oss_upload_endpoint' => trim((string)($rows['oss_upload_endpoint'] ?? '')), 'oss_bucket' => trim((string)($rows['oss_bucket'] ?? '')), 'oss_access_key_id' => trim((string)($rows['oss_access_key_id'] ?? '')), 'oss_access_key_secret' => trim((string)($rows['oss_access_key_secret'] ?? '')), 'oss_bucket_domain' => trim((string)($rows['oss_bucket_domain'] ?? '')), 'oss_path_prefix' => trim((string)($rows['oss_path_prefix'] ?? '')), + 'direct_upload_max_size_mb' => trim((string)($rows['direct_upload_max_size_mb'] ?? '200')), 'qiniu_bucket' => trim((string)($rows['qiniu_bucket'] ?? '')), 'qiniu_access_key' => trim((string)($rows['qiniu_access_key'] ?? '')), 'qiniu_secret_key' => trim((string)($rows['qiniu_secret_key'] ?? '')), @@ -136,6 +138,27 @@ class FileStorageConfigService return $this->normalizeEndpointHost($this->getConfig()['oss_endpoint']); } + public function uploadEndpoint(): string + { + $config = $this->getConfig(); + $endpoint = $config['oss_upload_endpoint'] !== '' ? $config['oss_upload_endpoint'] : $config['oss_endpoint']; + + return $this->normalizeEndpointHost($endpoint); + } + + public function directUploadMaxBytes(): int + { + $value = (int)$this->getConfig()['direct_upload_max_size_mb']; + $megabytes = max(1, min(2048, $value > 0 ? $value : 200)); + + return $megabytes * 1024 * 1024; + } + + public function directUploadMaxLabel(): string + { + return sprintf('%dMB', (int)($this->directUploadMaxBytes() / 1024 / 1024)); + } + public function accessKeyId(): string { return $this->getConfig()['oss_access_key_id']; diff --git a/server-api/app/support/FileUploadService.php b/server-api/app/support/FileUploadService.php new file mode 100644 index 0000000..6fb17fb --- /dev/null +++ b/server-api/app/support/FileUploadService.php @@ -0,0 +1,270 @@ + [ + 'base_dir' => 'uploads/appraisal-evidence', + 'filename_prefix' => 'evidence', + 'allowed_file_types' => ['image', 'video', 'pdf'], + 'invalid_type_message' => '仅支持上传图片、视频或 PDF 文件', + ], + 'zhongjian_report' => [ + 'base_dir' => 'uploads/zhongjian-report', + 'filename_prefix' => 'zhongjian', + 'allowed_file_types' => ['image', 'video', 'pdf'], + 'invalid_type_message' => '仅支持上传图片、视频或 PDF 文件', + ], + 'warehouse_inbound_evidence' => [ + 'base_dir' => 'uploads/warehouse-inbound-evidence', + 'filename_prefix' => 'inbound', + 'allowed_file_types' => ['image', 'video'], + 'invalid_type_message' => '拆包附件仅支持上传图片或视频', + ], + 'warehouse_return_packing' => [ + 'base_dir' => 'uploads/warehouse-return-packing', + 'filename_prefix' => 'packing', + 'allowed_file_types' => ['image', 'video'], + 'invalid_type_message' => '打包装箱附件仅支持上传图片或视频', + ], + ]; + + public function upload(Request $request, string $scene, string $inputName = 'file'): array + { + $sceneConfig = $this->sceneConfig($scene); + $file = $request->file($inputName); + if (!$file || !$file->isValid()) { + throw new \RuntimeException('上传文件无效'); + } + if ($file->getSize() > self::MAX_SERVER_UPLOAD_BYTES) { + throw new \RuntimeException('上传文件不能超过' . self::MAX_SERVER_UPLOAD_LABEL . ',请压缩后再上传'); + } + + $extension = strtolower($file->getUploadExtension() ?: ''); + $fileType = $this->detectFileType($extension); + $this->assertAllowedFileType($fileType, $sceneConfig); + + $relativePath = $this->buildRelativePath($sceneConfig, $extension ?: 'dat'); + $this->storage()->putUploadedFile($file, $relativePath); + + return $this->buildAsset( + $request, + $relativePath, + $fileType, + $extension, + (string)$file->getUploadName(), + '' + ); + } + + public function createOssDirectUploadPolicy( + Request $request, + string $scene, + string $originalName, + int $fileSize = 0, + string $mimeType = '' + ): array { + $config = $this->configService(); + if (!$config->isOss()) { + return ['enabled' => false]; + } + + $sceneConfig = $this->sceneConfig($scene); + $config->assertReady(); + $maxDirectUploadBytes = $config->directUploadMaxBytes(); + $maxDirectUploadLabel = $config->directUploadMaxLabel(); + + if ($fileSize > $maxDirectUploadBytes) { + throw new \RuntimeException('上传文件不能超过' . $maxDirectUploadLabel . ',请压缩后再上传'); + } + + $extension = $this->extensionFromNameOrMimeType($originalName, $mimeType); + $fileType = $this->detectFileType($extension); + $this->assertAllowedFileType($fileType, $sceneConfig); + + $relativePath = $this->buildRelativePath($sceneConfig, $extension); + $objectKey = $config->objectKey($relativePath); + $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 600); + $policy = base64_encode((string)json_encode([ + 'expiration' => $expiration, + 'conditions' => [ + ['content-length-range', 1, $maxDirectUploadBytes], + ['eq', '$key', $objectKey], + ['eq', '$success_action_status', '200'], + ], + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + $signature = base64_encode(hash_hmac('sha1', $policy, $config->accessKeySecret(), true)); + + return [ + 'enabled' => true, + 'upload_url' => sprintf('https://%s.%s', $config->bucket(), $config->uploadEndpoint()), + 'form_data' => [ + 'key' => $objectKey, + 'policy' => $policy, + 'OSSAccessKeyId' => $config->accessKeyId(), + 'signature' => $signature, + 'success_action_status' => '200', + ], + 'asset' => $this->buildAsset($request, $relativePath, $fileType, $extension, $originalName, $mimeType), + 'max_size' => $maxDirectUploadBytes, + 'max_size_text' => $maxDirectUploadLabel, + 'expires_at' => $expiration, + ]; + } + + public function delete(string $fileUrl): void + { + $relativePath = $this->storage()->storagePath($fileUrl); + if (!$this->isManagedUploadPath($relativePath)) { + return; + } + + $this->storage()->delete($relativePath); + } + + public function detectFileType(string $extension): string + { + if (in_array($extension, self::IMAGE_EXTENSIONS, true)) { + return 'image'; + } + if (in_array($extension, self::VIDEO_EXTENSIONS, true)) { + return 'video'; + } + if (in_array($extension, self::PDF_EXTENSIONS, true)) { + return 'pdf'; + } + return 'file'; + } + + public function mimeType(string $fileType, string $extension): string + { + return match ($fileType) { + 'image' => 'image/' . ($extension === 'jpg' ? 'jpeg' : ($extension ?: 'jpeg')), + 'video' => 'video/' . ($extension ?: 'mp4'), + 'pdf' => 'application/pdf', + default => 'application/octet-stream', + }; + } + + private function buildAsset( + Request $request, + string $relativePath, + string $fileType, + string $extension, + string $originalName, + string $mimeType + ): array { + $fileUrl = $this->storage()->publicUrl($request, $relativePath); + $name = trim($originalName) !== '' ? trim($originalName) : pathinfo($relativePath, PATHINFO_BASENAME); + + return [ + 'file_id' => md5($relativePath), + 'file_url' => $fileUrl, + 'thumbnail_url' => $fileType === 'image' ? $fileUrl : '', + 'name' => $name, + 'file_type' => $fileType, + 'mime_type' => trim($mimeType) !== '' ? trim($mimeType) : $this->mimeType($fileType, $extension), + ]; + } + + private function buildRelativePath(array $sceneConfig, string $extension): string + { + return sprintf( + '%s/%s/%s_%s.%s', + $sceneConfig['base_dir'], + date('Ymd'), + $sceneConfig['filename_prefix'], + uniqid(), + $extension + ); + } + + private function sceneConfig(string $scene): array + { + $scene = trim($scene); + if (!isset(self::SCENES[$scene])) { + throw new \RuntimeException('未知上传场景'); + } + + return self::SCENES[$scene]; + } + + private function assertAllowedFileType(string $fileType, array $sceneConfig): void + { + if (!in_array($fileType, $sceneConfig['allowed_file_types'], true)) { + throw new \RuntimeException((string)$sceneConfig['invalid_type_message']); + } + } + + private function extensionFromNameOrMimeType(string $originalName, string $mimeType): string + { + $extension = strtolower((string)pathinfo(trim($originalName), PATHINFO_EXTENSION)); + $extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: ''; + if ($extension !== '') { + return $extension; + } + + return match (strtolower(trim($mimeType))) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + 'image/bmp' => 'bmp', + 'image/heic' => 'heic', + 'video/mp4' => 'mp4', + 'video/quicktime' => 'mov', + 'video/x-m4v' => 'm4v', + 'video/webm' => 'webm', + 'video/x-msvideo' => 'avi', + 'video/mpeg' => 'mpeg', + 'application/pdf' => 'pdf', + default => '', + }; + } + + private function isManagedUploadPath(string $relativePath): bool + { + $relativePath = ltrim($relativePath, '/'); + foreach (self::SCENES as $sceneConfig) { + if (str_starts_with($relativePath, $sceneConfig['base_dir'] . '/')) { + return true; + } + } + + return false; + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } + + private function configService(): FileStorageConfigService + { + return new FileStorageConfigService(); + } +} diff --git a/server-api/config/route.php b/server-api/config/route.php index f72df04..bb4af71 100644 --- a/server-api/config/route.php +++ b/server-api/config/route.php @@ -46,6 +46,7 @@ use app\controller\admin\SystemConfigsController as AdminSystemConfigsController use app\controller\admin\AuthController as AdminAuthController; use app\controller\admin\CustomersController as AdminCustomersController; use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbenchController; +use app\controller\admin\FileUploadController as AdminFileUploadController; use app\controller\open\OrdersController as OpenOrdersController; Route::get('/', [app\controller\IndexController::class, 'json']); @@ -193,6 +194,7 @@ Route::get('/api/admin/ping', function () { Route::post('/api/admin/auth/login', [AdminAuthController::class, 'login']); Route::get('/api/admin/auth/me', [AdminAuthController::class, 'me']); Route::post('/api/admin/auth/logout', [AdminAuthController::class, 'logout']); +Route::post('/api/admin/file-upload/direct-policy', [AdminFileUploadController::class, 'directPolicy']); Route::get('/api/admin/dashboard', [AdminDashboardController::class, 'index']); Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']); Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']); diff --git a/server-api/config/server.php b/server-api/config/server.php index 238f1aa..ca213f7 100644 --- a/server-api/config/server.php +++ b/server-api/config/server.php @@ -18,6 +18,7 @@ return [ 'pid_file' => runtime_path() . '/webman.pid', 'status_file' => runtime_path() . '/webman.status', 'stdout_file' => runtime_path() . '/logs/stdout.log', - 'log_file' => runtime_path() . '/logs/workerman.log', - 'max_package_size' => 10 * 1024 * 1024 -]; + 'log_file' => runtime_path() . '/logs/workerman.log', + // Keep this above the 50m Nginx upload limit so Workerman does not reject videos first. + 'max_package_size' => 64 * 1024 * 1024 +]; diff --git a/user-app/src/pages/report/detail.vue b/user-app/src/pages/report/detail.vue index 6a370d9..4f49694 100644 --- a/user-app/src/pages/report/detail.vue +++ b/user-app/src/pages/report/detail.vue @@ -283,7 +283,7 @@ onLoad(async (options) => { - + {{ item.label }} {{ item.value || "-" }} diff --git a/work-app/src/api/admin.ts b/work-app/src/api/admin.ts index 387de6a..5e4f021 100644 --- a/work-app/src/api/admin.ts +++ b/work-app/src/api/admin.ts @@ -1,4 +1,4 @@ -import { request, uploadFile } from "../utils/request"; +import { request, uploadDirectFile, uploadFile } from "../utils/request"; import type { AdminSessionInfo } from "../utils/auth"; export interface AdminLoginResponse { @@ -15,6 +15,56 @@ export interface AdminFileAsset { mime_type?: string; } +export interface AdminDirectUploadMeta { + original_name?: string; + file_size?: number; + mime_type?: string; +} + +export type AdminUploadScene = "appraisal_evidence" | "zhongjian_report" | "warehouse_inbound_evidence" | "warehouse_return_packing"; + +export interface AdminDirectUploadPolicy { + enabled: boolean; + upload_url?: string; + form_data?: Record; + asset?: AdminFileAsset; + max_size?: number; + max_size_text?: string; + expires_at?: string; +} + +function filenameFromPath(filePath: string) { + return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`; +} + +async function uploadManagedAdminFile( + filePath: string, + scene: AdminUploadScene, + fallbackUrl: string, + meta: AdminDirectUploadMeta = {}, + fallbackFormData: Record = {}, +) { + const policy = await request("/api/admin/file-upload/direct-policy", { + method: "POST", + data: { + upload_scene: scene, + original_name: meta.original_name || filenameFromPath(filePath), + file_size: meta.file_size || 0, + mime_type: meta.mime_type || "", + }, + }); + const formData = { ...fallbackFormData, upload_scene: scene }; + if (!policy.enabled) { + return uploadFile(fallbackUrl, filePath, formData); + } + if (!policy.upload_url || !policy.form_data || !policy.asset) { + throw new Error("OSS 上传签名无效,请稍后重试"); + } + + await uploadDirectFile(policy.upload_url, filePath, policy.form_data); + return policy.asset; +} + export interface PaginatedList { list: T[]; total?: number; @@ -445,8 +495,13 @@ export const adminApi = { data, }); }, - uploadWarehouseInboundEvidenceFile(filePath: string) { - return uploadFile("/api/admin/warehouse-workbench/inbound/evidence/upload", filePath); + uploadWarehouseInboundEvidenceFile(filePath: string, meta: AdminDirectUploadMeta = {}) { + return uploadManagedAdminFile( + filePath, + "warehouse_inbound_evidence", + "/api/admin/warehouse-workbench/inbound/evidence/upload", + meta, + ); }, lookupZhongjianWarehouseTransfer(internalTagNo: string) { return request("/api/admin/warehouse-workbench/zhongjian/lookup", { @@ -488,8 +543,13 @@ export const adminApi = { data: { internal_tag_no: internalTagNo }, }); }, - uploadWarehouseReturnPackingFile(filePath: string) { - return uploadFile("/api/admin/warehouse-workbench/return/packing/upload", filePath); + uploadWarehouseReturnPackingFile(filePath: string, meta: AdminDirectUploadMeta = {}) { + return uploadManagedAdminFile( + filePath, + "warehouse_return_packing", + "/api/admin/warehouse-workbench/return/packing/upload", + meta, + ); }, shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) { return request("/api/admin/warehouse-workbench/return/ship", { @@ -530,8 +590,14 @@ export const adminApi = { data, }); }, - uploadAppraisalEvidenceFile(filePath: string, taskId?: number) { - return uploadFile("/api/admin/appraisal-task/evidence/upload", filePath, taskId ? { task_id: taskId } : {}); + uploadAppraisalEvidenceFile( + filePath: string, + taskId?: number, + meta: AdminDirectUploadMeta = {}, + scene: AdminUploadScene = "appraisal_evidence", + ) { + const formData: Record = taskId ? { task_id: taskId } : {}; + return uploadManagedAdminFile(filePath, scene, "/api/admin/appraisal-task/evidence/upload", meta, formData); }, deleteAppraisalEvidenceFile(fileUrl: string, taskId?: number) { return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", { diff --git a/work-app/src/pages/order/detail.vue b/work-app/src/pages/order/detail.vue index 6f66afc..cfb9cde 100644 --- a/work-app/src/pages/order/detail.vue +++ b/work-app/src/pages/order/detail.vue @@ -2,7 +2,7 @@ import { computed, ref } from "vue"; import { onLoad, onShow } from "@dcloudio/uni-app"; import { adminApi, type AdminFileAsset, type AdminOrderDetail } from "../../api/admin"; -import { showErrorToast } from "../../utils/feedback"; +import { showErrorToast, showInfoToast } from "../../utils/feedback"; const loading = ref(false); const pageReady = ref(false); @@ -40,6 +40,16 @@ function openReportDetail() { uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` }); } +function copyOrderNo() { + const orderNo = detail.value?.order_info.order_no || ""; + if (!orderNo) return; + uni.setClipboardData({ + data: orderNo, + success: () => showInfoToast("订单号已复制"), + fail: () => showInfoToast("复制失败,请重试"), + }); +} + function formatMoney(value?: number) { const amount = Number(value || 0); return `¥${amount.toFixed(2)}`; @@ -105,7 +115,15 @@ onShow(() => {