chore: sync release updates
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from "axios";
|
||||||
import request from "./request";
|
import request from "./request";
|
||||||
import type { AdminSessionInfo } from "../utils/auth";
|
import type { AdminSessionInfo } from "../utils/auth";
|
||||||
|
|
||||||
@@ -21,6 +22,59 @@ export interface AdminFileAsset {
|
|||||||
mime_type?: string;
|
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<string, string | number>;
|
||||||
|
asset?: AdminFileAsset;
|
||||||
|
max_size?: number;
|
||||||
|
max_size_text?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFileToOss(uploadUrl: string, formData: Record<string, string | number>, 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<string, string | number> = {}) {
|
||||||
|
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 {
|
export interface AdminTransferFlowSummary {
|
||||||
id: number;
|
id: number;
|
||||||
internal_tag_no: string;
|
internal_tag_no: string;
|
||||||
@@ -1800,24 +1854,12 @@ export const adminApi = {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
uploadAppraisalEvidenceFile(file: File) {
|
uploadAppraisalEvidenceFile(file: File, scene: AdminUploadScene = "appraisal_evidence", taskId?: number) {
|
||||||
const formData = new FormData();
|
const fallbackFields: Record<string, string | number> = taskId ? { task_id: taskId } : {};
|
||||||
formData.append("file", file);
|
return uploadManagedAdminFile(file, scene, "/api/admin/appraisal-task/evidence/upload", fallbackFields) as Promise<{
|
||||||
return request.post("/api/admin/appraisal-task/evidence/upload", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
}) as Promise<{
|
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: {
|
data: AdminFileAsset;
|
||||||
file_id: string;
|
|
||||||
file_url: string;
|
|
||||||
thumbnail_url: string;
|
|
||||||
name?: string;
|
|
||||||
file_type?: string;
|
|
||||||
mime_type?: string;
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
deleteAppraisalEvidenceFile(fileUrl: string) {
|
deleteAppraisalEvidenceFile(fileUrl: string) {
|
||||||
@@ -1888,14 +1930,15 @@ export const adminApi = {
|
|||||||
internal_tag_no: internalTagNo,
|
internal_tag_no: internalTagNo,
|
||||||
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
|
}) 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) {
|
uploadWarehouseReturnPackingFile(file: File) {
|
||||||
const formData = new FormData();
|
return uploadManagedAdminFile(file, "warehouse_return_packing", "/api/admin/warehouse-workbench/return/packing/upload") as Promise<{
|
||||||
formData.append("file", file);
|
|
||||||
return request.post("/api/admin/warehouse-workbench/return/packing/upload", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
}) as Promise<{
|
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: AdminFileAsset;
|
data: AdminFileAsset;
|
||||||
|
|||||||
@@ -579,6 +579,9 @@ function triggerEvidenceUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleEvidenceFileSelect(event: Event) {
|
async function handleEvidenceFileSelect(event: Event) {
|
||||||
|
if (!detail.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
const files = Array.from(target.files || []);
|
const files = Array.from(target.files || []);
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
@@ -588,7 +591,7 @@ async function handleEvidenceFileSelect(event: Event) {
|
|||||||
evidenceUploading.value = true;
|
evidenceUploading.value = true;
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
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);
|
resultAttachments.value.push(response.data);
|
||||||
}
|
}
|
||||||
ElMessage.success("附件上传成功");
|
ElMessage.success("附件上传成功");
|
||||||
@@ -609,6 +612,9 @@ function triggerZhongjianReportUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleZhongjianReportFileSelect(event: Event) {
|
async function handleZhongjianReportFileSelect(event: Event) {
|
||||||
|
if (!detail.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
const files = Array.from(target.files || []);
|
const files = Array.from(target.files || []);
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
@@ -618,7 +624,7 @@ async function handleZhongjianReportFileSelect(event: Event) {
|
|||||||
zhongjianReportUploading.value = true;
|
zhongjianReportUploading.value = true;
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
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);
|
zhongjianReportFiles.value.push(response.data);
|
||||||
}
|
}
|
||||||
ElMessage.success("中检报告文件已上传");
|
ElMessage.success("中检报告文件已上传");
|
||||||
|
|||||||
@@ -596,6 +596,16 @@ watch(
|
|||||||
<div class="detail-label">说明</div>
|
<div class="detail-label">说明</div>
|
||||||
<div class="detail-value">{{ detail.result_info.result_desc || "-" }}</div>
|
<div class="detail-value">{{ detail.result_info.result_desc || "-" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="detail.result_info.key_points?.length">
|
||||||
|
<div v-for="(item, index) in detail.result_info.key_points" :key="`${item.point_code || item.point_name}-${index}`" class="detail-card__desc">
|
||||||
|
<div class="detail-label">{{ item.point_name || "鉴定项" }}</div>
|
||||||
|
<div class="detail-value">{{ [item.point_value, item.point_remark].filter(Boolean).join(";") || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="detail.result_info.external_remark" class="detail-card__desc">
|
||||||
|
<div class="detail-label">对外备注</div>
|
||||||
|
<div class="detail-value">{{ detail.result_info.external_remark }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
@@ -634,6 +644,10 @@ watch(
|
|||||||
<div class="detail-label">成色评级</div>
|
<div class="detail-label">成色评级</div>
|
||||||
<div class="detail-value">{{ detail.valuation_info.condition_grade || "-" }}</div>
|
<div class="detail-value">{{ detail.valuation_info.condition_grade || "-" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">成色说明</div>
|
||||||
|
<div class="detail-value">{{ detail.valuation_info.condition_desc || "-" }}</div>
|
||||||
|
</div>
|
||||||
<div class="detail-card__desc">
|
<div class="detail-card__desc">
|
||||||
<div class="detail-label">估值区间</div>
|
<div class="detail-label">估值区间</div>
|
||||||
<div class="detail-value">¥{{ detail.valuation_info.valuation_min || 0 }} - ¥{{ detail.valuation_info.valuation_max || 0 }}</div>
|
<div class="detail-value">¥{{ detail.valuation_info.valuation_min || 0 }} - ¥{{ detail.valuation_info.valuation_max || 0 }}</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const returnTagNo = ref("");
|
|||||||
const returnMaterialQr = ref("");
|
const returnMaterialQr = ref("");
|
||||||
const returnExpressCompany = ref("");
|
const returnExpressCompany = ref("");
|
||||||
const returnTrackingNo = ref("");
|
const returnTrackingNo = ref("");
|
||||||
|
const inboundAttachments = ref<AdminFileAsset[]>([]);
|
||||||
const returnPackingAttachments = ref<AdminFileAsset[]>([]);
|
const returnPackingAttachments = ref<AdminFileAsset[]>([]);
|
||||||
|
|
||||||
const inboundContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
const inboundContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
@@ -34,6 +35,7 @@ const returnTrackingInputRef = ref<InputInstance | null>(null);
|
|||||||
const returnReviewDrawerVisible = ref(false);
|
const returnReviewDrawerVisible = ref(false);
|
||||||
const returnReviewLoading = ref(false);
|
const returnReviewLoading = ref(false);
|
||||||
const returnConfirmLoading = ref(false);
|
const returnConfirmLoading = ref(false);
|
||||||
|
const inboundUploading = ref(false);
|
||||||
const returnPackingUploading = ref(false);
|
const returnPackingUploading = ref(false);
|
||||||
|
|
||||||
const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian");
|
const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian");
|
||||||
@@ -157,6 +159,7 @@ async function lookupInbound() {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
inboundAttachments.value = [];
|
||||||
const response = await adminApi.lookupWarehouseInbound(trackingNo);
|
const response = await adminApi.lookupWarehouseInbound(trackingNo);
|
||||||
inboundContext.value = response.data;
|
inboundContext.value = response.data;
|
||||||
ElMessage.success("已匹配订单");
|
ElMessage.success("已匹配订单");
|
||||||
@@ -179,13 +182,19 @@ async function receiveInbound() {
|
|||||||
ElMessage.warning("请扫描内部流转挂牌");
|
ElMessage.warning("请扫描内部流转挂牌");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (inboundUploading.value) {
|
||||||
|
ElMessage.warning("入库附件上传中,请稍后提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
actionLoading.value = true;
|
actionLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.receiveWarehouseInbound({
|
const response = await adminApi.receiveWarehouseInbound({
|
||||||
inbound_no: inboundTrackingNo.value.trim(),
|
inbound_no: inboundTrackingNo.value.trim(),
|
||||||
internal_tag_no: inboundTagNo.value.trim(),
|
internal_tag_no: inboundTagNo.value.trim(),
|
||||||
|
inbound_attachments: inboundAttachments.value,
|
||||||
});
|
});
|
||||||
inboundContext.value = response.data;
|
inboundContext.value = response.data;
|
||||||
|
inboundAttachments.value = [];
|
||||||
ElMessage.success("入库完成");
|
ElMessage.success("入库完成");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || "入库失败");
|
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() {
|
async function lookupZhongjian() {
|
||||||
if (!zhongjianTagNo.value.trim()) {
|
if (!zhongjianTagNo.value.trim()) {
|
||||||
ElMessage.warning("请扫描内部流转码");
|
ElMessage.warning("请扫描内部流转码");
|
||||||
@@ -442,7 +473,30 @@ function openFile(url: string) {
|
|||||||
<el-input ref="inboundTagInputRef" v-model="inboundTagNo" size="large" placeholder="扫描内部流转挂牌" clearable @keyup.enter="receiveInbound" />
|
<el-input ref="inboundTagInputRef" v-model="inboundTagNo" size="large" placeholder="扫描内部流转挂牌" clearable @keyup.enter="receiveInbound" />
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
<el-button type="primary" :loading="loading" @click="lookupInbound">匹配订单</el-button>
|
<el-button type="primary" :loading="loading" @click="lookupInbound">匹配订单</el-button>
|
||||||
<el-button type="success" :loading="actionLoading" :disabled="!inboundContext" @click="receiveInbound">绑定挂牌并入库</el-button>
|
<el-button type="success" :loading="actionLoading" :disabled="!inboundContext || inboundUploading" @click="receiveInbound">绑定挂牌并入库</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="inboundContext" class="packing-upload">
|
||||||
|
<div class="packing-upload-head">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="uploadInboundAttachment"
|
||||||
|
:disabled="inboundUploading"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<el-button :loading="inboundUploading">上传拆包图片/视频</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<span class="packing-upload-hint">{{ inboundAttachments.length }} 个入库附件</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="inboundAttachments.length" class="packing-file-list">
|
||||||
|
<div v-for="file in inboundAttachments" :key="file.file_url" class="packing-file-item">
|
||||||
|
<button class="file-button" type="button" @click="openFile(file.file_url)">
|
||||||
|
{{ file.name || file.file_url }}
|
||||||
|
</button>
|
||||||
|
<span class="packing-file-type">{{ fileTypeText(file) }}</span>
|
||||||
|
<el-button link type="danger" @click="removeInboundAttachment(file.file_url)">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -1045,7 +1045,11 @@ class AppraisalTasksController
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
return api_success($asset);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return api_error($e->getMessage(), 422);
|
return api_error($e->getMessage(), 422);
|
||||||
@@ -1859,11 +1863,13 @@ class AppraisalTasksController
|
|||||||
'brand_name' => $product['brand_name'] ?? '',
|
'brand_name' => $product['brand_name'] ?? '',
|
||||||
'color' => $product['color'] ?? '',
|
'color' => $product['color'] ?? '',
|
||||||
'size_spec' => $product['size_spec'] ?? '',
|
'size_spec' => $product['size_spec'] ?? '',
|
||||||
|
'serial_no' => $product['serial_no'] ?? '',
|
||||||
], JSON_UNESCAPED_UNICODE),
|
], JSON_UNESCAPED_UNICODE),
|
||||||
'result_snapshot_json' => json_encode([
|
'result_snapshot_json' => json_encode([
|
||||||
'result_status' => $resultPayload['result_status'],
|
'result_status' => $resultPayload['result_status'],
|
||||||
'result_text' => $resultPayload['result_text'],
|
'result_text' => $resultPayload['result_text'],
|
||||||
'result_desc' => $resultPayload['result_desc'],
|
'result_desc' => $resultPayload['result_desc'],
|
||||||
|
'external_remark' => $resultPayload['external_remark'] ?? '',
|
||||||
'key_points' => $this->loadLatestOrderKeyPoints($orderId),
|
'key_points' => $this->loadLatestOrderKeyPoints($orderId),
|
||||||
], JSON_UNESCAPED_UNICODE),
|
], JSON_UNESCAPED_UNICODE),
|
||||||
'appraisal_snapshot_json' => json_encode($appraisalSnapshot, JSON_UNESCAPED_UNICODE),
|
'appraisal_snapshot_json' => json_encode($appraisalSnapshot, JSON_UNESCAPED_UNICODE),
|
||||||
|
|||||||
24
server-api/app/controller/admin/FileUploadController.php
Normal file
24
server-api/app/controller/admin/FileUploadController.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\controller\admin;
|
||||||
|
|
||||||
|
use app\support\FileUploadService;
|
||||||
|
use support\Request;
|
||||||
|
|
||||||
|
class FileUploadController
|
||||||
|
{
|
||||||
|
public function directPolicy(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success((new FileUploadService())->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,7 +275,16 @@ class SystemConfigsController
|
|||||||
'title' => 'OSS Endpoint',
|
'title' => 'OSS Endpoint',
|
||||||
'field_type' => 'text',
|
'field_type' => 'text',
|
||||||
'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com',
|
'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,
|
'is_secret' => false,
|
||||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
||||||
],
|
],
|
||||||
@@ -324,6 +333,16 @@ class SystemConfigsController
|
|||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
|
'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',
|
'config_key' => 'qiniu_bucket',
|
||||||
'title' => '七牛 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class WarehouseWorkbenchController
|
|||||||
{
|
{
|
||||||
$evidenceService = new AppraisalEvidenceService();
|
$evidenceService = new AppraisalEvidenceService();
|
||||||
try {
|
try {
|
||||||
$asset = $evidenceService->upload($request);
|
$asset = $evidenceService->upload($request, 'file', 'warehouse_inbound_evidence');
|
||||||
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||||
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
||||||
return api_error('拆包附件仅支持上传图片或视频', 422);
|
return api_error('拆包附件仅支持上传图片或视频', 422);
|
||||||
@@ -59,7 +59,7 @@ class WarehouseWorkbenchController
|
|||||||
{
|
{
|
||||||
$evidenceService = new AppraisalEvidenceService();
|
$evidenceService = new AppraisalEvidenceService();
|
||||||
try {
|
try {
|
||||||
$asset = $evidenceService->upload($request);
|
$asset = $evidenceService->upload($request, 'file', 'warehouse_return_packing');
|
||||||
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||||
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
||||||
return api_error('打包装箱附件仅支持上传图片或视频', 422);
|
return api_error('打包装箱附件仅支持上传图片或视频', 422);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class ReportsController
|
|||||||
'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null),
|
'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null),
|
||||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
'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 = [
|
$reportMedia = [
|
||||||
'images' => $this->filterAssetsByType($evidenceAttachments, 'image'),
|
'images' => $this->filterAssetsByType($evidenceAttachments, 'image'),
|
||||||
];
|
];
|
||||||
@@ -261,7 +261,7 @@ class ReportsController
|
|||||||
->find();
|
->find();
|
||||||
$publishTime = (string)($report['publish_time'] ?: date('Y-m-d H:i:s'));
|
$publishTime = (string)($report['publish_time'] ?: date('Y-m-d H:i:s'));
|
||||||
$relativeDir = 'uploads/reports/' . date('Ymd', strtotime($publishTime));
|
$relativeDir = 'uploads/reports/' . date('Ymd', strtotime($publishTime));
|
||||||
$filename = $report['report_no'] . '-v2.pdf';
|
$filename = $report['report_no'] . '-v3.pdf';
|
||||||
$relativePath = $relativeDir . '/' . $filename;
|
$relativePath = $relativeDir . '/' . $filename;
|
||||||
|
|
||||||
if ($existingFile && !empty($existingFile['file_url'])) {
|
if ($existingFile && !empty($existingFile['file_url'])) {
|
||||||
@@ -317,20 +317,26 @@ class ReportsController
|
|||||||
return $this->storage()->publicUrl($request, $relativePath);
|
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 = [
|
$items = [];
|
||||||
[
|
$this->appendDisplayItem(
|
||||||
'label' => '检测结论',
|
$items,
|
||||||
'value' => $this->textValue($resultInfo['result_text'] ?? '') ?: '-',
|
'检测结论',
|
||||||
'remark' => $this->textValue($resultInfo['result_desc'] ?? ''),
|
$this->textValue($resultInfo['result_text'] ?? '') ?: '-',
|
||||||
],
|
$this->textValue($resultInfo['result_desc'] ?? ''),
|
||||||
[
|
true
|
||||||
'label' => '品牌',
|
);
|
||||||
'value' => $this->textValue($productInfo['brand_name'] ?? '') ?: '-',
|
|
||||||
'remark' => '',
|
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) {
|
foreach (($resultInfo['key_points'] ?? []) as $point) {
|
||||||
if (!is_array($point)) {
|
if (!is_array($point)) {
|
||||||
@@ -340,11 +346,30 @@ class ReportsController
|
|||||||
if ($label === '') {
|
if ($label === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$items[] = [
|
$this->appendDisplayItem(
|
||||||
'label' => $label,
|
$items,
|
||||||
'value' => $this->textValue($point['point_value'] ?? '') ?: '-',
|
$label,
|
||||||
'remark' => $this->textValue($point['point_remark'] ?? ''),
|
$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 [
|
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
|
private function buildTraceInfo(int $orderId, array $appraisalInfo, array $evidenceAttachments, Request $request): array
|
||||||
{
|
{
|
||||||
$logs = $orderId > 0
|
$logs = $orderId > 0
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
|||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'],
|
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/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/orders') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
||||||
str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
||||||
|
|||||||
@@ -3,54 +3,20 @@
|
|||||||
namespace app\support;
|
namespace app\support;
|
||||||
|
|
||||||
use support\Request;
|
use support\Request;
|
||||||
|
use function pathinfo;
|
||||||
use function parse_url;
|
use function parse_url;
|
||||||
use function str_starts_with;
|
|
||||||
use function strtolower;
|
use function strtolower;
|
||||||
|
|
||||||
class AppraisalEvidenceService
|
class AppraisalEvidenceService
|
||||||
{
|
{
|
||||||
private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'heic'];
|
public function upload(Request $request, string $inputName = 'file', string $scene = 'appraisal_evidence'): array
|
||||||
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
|
|
||||||
{
|
{
|
||||||
$file = $request->file($inputName);
|
return $this->fileUploadService()->upload($request, $scene, $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),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $fileUrl): void
|
public function delete(string $fileUrl): void
|
||||||
{
|
{
|
||||||
$relativePath = $this->storage()->storagePath($fileUrl);
|
$this->fileUploadService()->delete($fileUrl);
|
||||||
if (!str_starts_with($relativePath, 'uploads/appraisal-evidence/')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->storage()->delete($relativePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array
|
public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array
|
||||||
@@ -112,30 +78,21 @@ class AppraisalEvidenceService
|
|||||||
|
|
||||||
public function detectFileType(string $extension): string
|
public function detectFileType(string $extension): string
|
||||||
{
|
{
|
||||||
if (in_array($extension, self::IMAGE_EXTENSIONS, true)) {
|
return $this->fileUploadService()->detectFileType($extension);
|
||||||
return 'image';
|
|
||||||
}
|
|
||||||
if (in_array($extension, self::VIDEO_EXTENSIONS, true)) {
|
|
||||||
return 'video';
|
|
||||||
}
|
|
||||||
if (in_array($extension, self::PDF_EXTENSIONS, true)) {
|
|
||||||
return 'pdf';
|
|
||||||
}
|
|
||||||
return 'file';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mimeType(string $fileType, string $extension): string
|
private function mimeType(string $fileType, string $extension): string
|
||||||
{
|
{
|
||||||
return match ($fileType) {
|
return $this->fileUploadService()->mimeType($fileType, $extension);
|
||||||
'image' => 'image/' . ($extension === 'jpg' ? 'jpeg' : ($extension ?: 'jpeg')),
|
|
||||||
'video' => 'video/' . ($extension ?: 'mp4'),
|
|
||||||
'pdf' => 'application/pdf',
|
|
||||||
default => 'application/octet-stream',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function storage(): FileStorageService
|
private function storage(): FileStorageService
|
||||||
{
|
{
|
||||||
return new FileStorageService();
|
return new FileStorageService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function fileUploadService(): FileUploadService
|
||||||
|
{
|
||||||
|
return new FileUploadService();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ class FileStorageConfigService
|
|||||||
'driver' => $this->normalizeDriver((string)($rows['driver'] ?? 'local')),
|
'driver' => $this->normalizeDriver((string)($rows['driver'] ?? 'local')),
|
||||||
'public_base_url' => trim((string)($rows['public_base_url'] ?? '')),
|
'public_base_url' => trim((string)($rows['public_base_url'] ?? '')),
|
||||||
'oss_endpoint' => trim((string)($rows['oss_endpoint'] ?? '')),
|
'oss_endpoint' => trim((string)($rows['oss_endpoint'] ?? '')),
|
||||||
|
'oss_upload_endpoint' => trim((string)($rows['oss_upload_endpoint'] ?? '')),
|
||||||
'oss_bucket' => trim((string)($rows['oss_bucket'] ?? '')),
|
'oss_bucket' => trim((string)($rows['oss_bucket'] ?? '')),
|
||||||
'oss_access_key_id' => trim((string)($rows['oss_access_key_id'] ?? '')),
|
'oss_access_key_id' => trim((string)($rows['oss_access_key_id'] ?? '')),
|
||||||
'oss_access_key_secret' => trim((string)($rows['oss_access_key_secret'] ?? '')),
|
'oss_access_key_secret' => trim((string)($rows['oss_access_key_secret'] ?? '')),
|
||||||
'oss_bucket_domain' => trim((string)($rows['oss_bucket_domain'] ?? '')),
|
'oss_bucket_domain' => trim((string)($rows['oss_bucket_domain'] ?? '')),
|
||||||
'oss_path_prefix' => trim((string)($rows['oss_path_prefix'] ?? '')),
|
'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_bucket' => trim((string)($rows['qiniu_bucket'] ?? '')),
|
||||||
'qiniu_access_key' => trim((string)($rows['qiniu_access_key'] ?? '')),
|
'qiniu_access_key' => trim((string)($rows['qiniu_access_key'] ?? '')),
|
||||||
'qiniu_secret_key' => trim((string)($rows['qiniu_secret_key'] ?? '')),
|
'qiniu_secret_key' => trim((string)($rows['qiniu_secret_key'] ?? '')),
|
||||||
@@ -136,6 +138,27 @@ class FileStorageConfigService
|
|||||||
return $this->normalizeEndpointHost($this->getConfig()['oss_endpoint']);
|
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
|
public function accessKeyId(): string
|
||||||
{
|
{
|
||||||
return $this->getConfig()['oss_access_key_id'];
|
return $this->getConfig()['oss_access_key_id'];
|
||||||
|
|||||||
270
server-api/app/support/FileUploadService.php
Normal file
270
server-api/app/support/FileUploadService.php
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\support;
|
||||||
|
|
||||||
|
use support\Request;
|
||||||
|
use function base64_encode;
|
||||||
|
use function gmdate;
|
||||||
|
use function hash_hmac;
|
||||||
|
use function in_array;
|
||||||
|
use function json_encode;
|
||||||
|
use function ltrim;
|
||||||
|
use function pathinfo;
|
||||||
|
use function preg_replace;
|
||||||
|
use function sprintf;
|
||||||
|
use function str_starts_with;
|
||||||
|
use function strtolower;
|
||||||
|
use function time;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
class FileUploadService
|
||||||
|
{
|
||||||
|
public const MAX_SERVER_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||||
|
public const MAX_SERVER_UPLOAD_LABEL = '50MB';
|
||||||
|
|
||||||
|
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'];
|
||||||
|
|
||||||
|
private const SCENES = [
|
||||||
|
'appraisal_evidence' => [
|
||||||
|
'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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ use app\controller\admin\SystemConfigsController as AdminSystemConfigsController
|
|||||||
use app\controller\admin\AuthController as AdminAuthController;
|
use app\controller\admin\AuthController as AdminAuthController;
|
||||||
use app\controller\admin\CustomersController as AdminCustomersController;
|
use app\controller\admin\CustomersController as AdminCustomersController;
|
||||||
use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbenchController;
|
use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbenchController;
|
||||||
|
use app\controller\admin\FileUploadController as AdminFileUploadController;
|
||||||
use app\controller\open\OrdersController as OpenOrdersController;
|
use app\controller\open\OrdersController as OpenOrdersController;
|
||||||
|
|
||||||
Route::get('/', [app\controller\IndexController::class, 'json']);
|
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::post('/api/admin/auth/login', [AdminAuthController::class, 'login']);
|
||||||
Route::get('/api/admin/auth/me', [AdminAuthController::class, 'me']);
|
Route::get('/api/admin/auth/me', [AdminAuthController::class, 'me']);
|
||||||
Route::post('/api/admin/auth/logout', [AdminAuthController::class, 'logout']);
|
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/dashboard', [AdminDashboardController::class, 'index']);
|
||||||
Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']);
|
Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']);
|
||||||
Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']);
|
Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ return [
|
|||||||
'pid_file' => runtime_path() . '/webman.pid',
|
'pid_file' => runtime_path() . '/webman.pid',
|
||||||
'status_file' => runtime_path() . '/webman.status',
|
'status_file' => runtime_path() . '/webman.status',
|
||||||
'stdout_file' => runtime_path() . '/logs/stdout.log',
|
'stdout_file' => runtime_path() . '/logs/stdout.log',
|
||||||
'log_file' => runtime_path() . '/logs/workerman.log',
|
'log_file' => runtime_path() . '/logs/workerman.log',
|
||||||
'max_package_size' => 10 * 1024 * 1024
|
// Keep this above the 50m Nginx upload limit so Workerman does not reject videos first.
|
||||||
];
|
'max_package_size' => 64 * 1024 * 1024
|
||||||
|
];
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ onLoad(async (options) => {
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="activeTab === 'product'" class="report-panel">
|
<view v-if="activeTab === 'product'" class="report-panel">
|
||||||
<view v-for="item in productItems" :key="item.label" class="product-row">
|
<view v-for="(item, index) in productItems" :key="`${item.label}-${index}`" class="product-row">
|
||||||
<view class="product-row__label">{{ item.label }}</view>
|
<view class="product-row__label">{{ item.label }}</view>
|
||||||
<view class="product-row__value" :class="item.label === '检测结论' ? 'product-row__value--result' : ''">
|
<view class="product-row__value" :class="item.label === '检测结论' ? 'product-row__value--result' : ''">
|
||||||
{{ item.value || "-" }}
|
{{ item.value || "-" }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { request, uploadFile } from "../utils/request";
|
import { request, uploadDirectFile, uploadFile } from "../utils/request";
|
||||||
import type { AdminSessionInfo } from "../utils/auth";
|
import type { AdminSessionInfo } from "../utils/auth";
|
||||||
|
|
||||||
export interface AdminLoginResponse {
|
export interface AdminLoginResponse {
|
||||||
@@ -15,6 +15,56 @@ export interface AdminFileAsset {
|
|||||||
mime_type?: string;
|
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<string, string | number>;
|
||||||
|
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<string, string | number> = {},
|
||||||
|
) {
|
||||||
|
const policy = await request<AdminDirectUploadPolicy>("/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<AdminFileAsset>(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<T> {
|
export interface PaginatedList<T> {
|
||||||
list: T[];
|
list: T[];
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -445,8 +495,13 @@ export const adminApi = {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
uploadWarehouseInboundEvidenceFile(filePath: string) {
|
uploadWarehouseInboundEvidenceFile(filePath: string, meta: AdminDirectUploadMeta = {}) {
|
||||||
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/inbound/evidence/upload", filePath);
|
return uploadManagedAdminFile(
|
||||||
|
filePath,
|
||||||
|
"warehouse_inbound_evidence",
|
||||||
|
"/api/admin/warehouse-workbench/inbound/evidence/upload",
|
||||||
|
meta,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
||||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
||||||
@@ -488,8 +543,13 @@ export const adminApi = {
|
|||||||
data: { internal_tag_no: internalTagNo },
|
data: { internal_tag_no: internalTagNo },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
uploadWarehouseReturnPackingFile(filePath: string) {
|
uploadWarehouseReturnPackingFile(filePath: string, meta: AdminDirectUploadMeta = {}) {
|
||||||
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/return/packing/upload", filePath);
|
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[] }) {
|
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) {
|
||||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
|
||||||
@@ -530,8 +590,14 @@ export const adminApi = {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
uploadAppraisalEvidenceFile(filePath: string, taskId?: number) {
|
uploadAppraisalEvidenceFile(
|
||||||
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath, taskId ? { task_id: taskId } : {});
|
filePath: string,
|
||||||
|
taskId?: number,
|
||||||
|
meta: AdminDirectUploadMeta = {},
|
||||||
|
scene: AdminUploadScene = "appraisal_evidence",
|
||||||
|
) {
|
||||||
|
const formData: Record<string, string | number> = taskId ? { task_id: taskId } : {};
|
||||||
|
return uploadManagedAdminFile(filePath, scene, "/api/admin/appraisal-task/evidence/upload", meta, formData);
|
||||||
},
|
},
|
||||||
deleteAppraisalEvidenceFile(fileUrl: string, taskId?: number) {
|
deleteAppraisalEvidenceFile(fileUrl: string, taskId?: number) {
|
||||||
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
|
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
import { adminApi, type AdminFileAsset, type AdminOrderDetail } from "../../api/admin";
|
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 loading = ref(false);
|
||||||
const pageReady = ref(false);
|
const pageReady = ref(false);
|
||||||
@@ -40,6 +40,16 @@ function openReportDetail() {
|
|||||||
uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` });
|
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) {
|
function formatMoney(value?: number) {
|
||||||
const amount = Number(value || 0);
|
const amount = Number(value || 0);
|
||||||
return `¥${amount.toFixed(2)}`;
|
return `¥${amount.toFixed(2)}`;
|
||||||
@@ -105,7 +115,15 @@ onShow(() => {
|
|||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<view class="hero">
|
<view class="hero">
|
||||||
<view class="eyebrow">订单详情</view>
|
<view class="eyebrow">订单详情</view>
|
||||||
<view class="title">{{ pageTitle }}</view>
|
<view class="order-title-row">
|
||||||
|
<view class="title order-title">{{ pageTitle }}</view>
|
||||||
|
<button class="copy-order-button" aria-label="复制订单号" hover-class="copy-order-button--active" @click.stop="copyOrderNo">
|
||||||
|
<view class="copy-order-button__icon">
|
||||||
|
<view class="copy-order-button__sheet copy-order-button__sheet--back"></view>
|
||||||
|
<view class="copy-order-button__sheet copy-order-button__sheet--front"></view>
|
||||||
|
</view>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
<view class="subtitle">{{ detail.order_info.appraisal_no }}</view>
|
<view class="subtitle">{{ detail.order_info.appraisal_no }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -261,6 +279,65 @@ onShow(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.order-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-order-button {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-order-button--active {
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-order-button__icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 36rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-order-button__sheet {
|
||||||
|
position: absolute;
|
||||||
|
width: 26rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
border: 3rpx solid var(--work-text);
|
||||||
|
border-radius: 5rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-order-button__sheet--back {
|
||||||
|
left: 1rpx;
|
||||||
|
top: 1rpx;
|
||||||
|
border-color: var(--work-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-order-button__sheet--front {
|
||||||
|
right: 1rpx;
|
||||||
|
bottom: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ const resultMetaItems = computed(() => {
|
|||||||
items.push({ label: "估值", value: [range, valuationDesc].filter(Boolean).join(";") || "-" });
|
items.push({ label: "估值", value: [range, valuationDesc].filter(Boolean).join(";") || "-" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const externalRemark = textValue(result.external_remark);
|
||||||
|
if (externalRemark) {
|
||||||
|
items.push({ label: "备注", value: externalRemark });
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
const evidenceAttachments = computed(() => detail.value?.evidence_attachments || []);
|
const evidenceAttachments = computed(() => detail.value?.evidence_attachments || []);
|
||||||
|
|||||||
@@ -120,11 +120,18 @@ async function choosePackingVideo() {
|
|||||||
try {
|
try {
|
||||||
const result = await uni.chooseVideo({
|
const result = await uni.chooseVideo({
|
||||||
sourceType: ["album", "camera"],
|
sourceType: ["album", "camera"],
|
||||||
|
compressed: true,
|
||||||
|
maxDuration: 600,
|
||||||
});
|
});
|
||||||
const filePath = result.tempFilePath;
|
const filePath = result.tempFilePath;
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath);
|
const tempFile = result.tempFile as File | undefined;
|
||||||
|
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath, {
|
||||||
|
original_name: tempFile?.name,
|
||||||
|
file_size: Number(result.size || tempFile?.size || 0),
|
||||||
|
mime_type: tempFile?.type || "video/mp4",
|
||||||
|
});
|
||||||
packingAttachments.value.push(asset);
|
packingAttachments.value.push(asset);
|
||||||
showInfoToast("视频上传成功");
|
showInfoToast("视频上传成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -250,11 +250,18 @@ async function chooseInboundVideo() {
|
|||||||
try {
|
try {
|
||||||
const result = await uni.chooseVideo({
|
const result = await uni.chooseVideo({
|
||||||
sourceType: ["album", "camera"],
|
sourceType: ["album", "camera"],
|
||||||
|
compressed: true,
|
||||||
|
maxDuration: 600,
|
||||||
});
|
});
|
||||||
const filePath = result.tempFilePath;
|
const filePath = result.tempFilePath;
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
uploadingInbound.value = true;
|
uploadingInbound.value = true;
|
||||||
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath);
|
const tempFile = result.tempFile as File | undefined;
|
||||||
|
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath, {
|
||||||
|
original_name: tempFile?.name,
|
||||||
|
file_size: Number(result.size || tempFile?.size || 0),
|
||||||
|
mime_type: tempFile?.type || "video/mp4",
|
||||||
|
});
|
||||||
inboundAttachments.value.push(asset);
|
inboundAttachments.value.push(asset);
|
||||||
showInfoToast("视频上传成功");
|
showInfoToast("视频上传成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -238,13 +238,30 @@ function updateTemplatePoint(index: number, key: "point_value" | "point_remark",
|
|||||||
current[key] = value;
|
current[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inputEventValue(event: unknown) {
|
||||||
|
if (typeof event === "string" || typeof event === "number") {
|
||||||
|
return String(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputEvent = event as {
|
||||||
|
detail?: { value?: unknown };
|
||||||
|
target?: { value?: unknown };
|
||||||
|
};
|
||||||
|
if (inputEvent?.detail && "value" in inputEvent.detail) {
|
||||||
|
return String(inputEvent.detail.value ?? "");
|
||||||
|
}
|
||||||
|
if (inputEvent?.target && "value" in inputEvent.target) {
|
||||||
|
return String(inputEvent.target.value ?? "");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function updateTemplatePointFromInput(
|
function updateTemplatePointFromInput(
|
||||||
index: number,
|
index: number,
|
||||||
key: "point_value" | "point_remark",
|
key: "point_value" | "point_remark",
|
||||||
event: Event,
|
event: unknown,
|
||||||
) {
|
) {
|
||||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
|
updateTemplatePoint(index, key, inputEventValue(event));
|
||||||
updateTemplatePoint(index, key, target?.value || "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateKeyPointsPayload() {
|
function templateKeyPointsPayload() {
|
||||||
@@ -256,6 +273,15 @@ function templateKeyPointsPayload() {
|
|||||||
})) || [];
|
})) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateRequiredTemplatePoints() {
|
||||||
|
const missing = detail.value?.appraisal_template?.key_points?.find((item) => item.is_required && !String(item.point_value || "").trim());
|
||||||
|
if (!missing) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
showInfoToast(`请填写鉴定模板项:${missing.point_name}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function returnToWorkOrders(message: string) {
|
function returnToWorkOrders(message: string) {
|
||||||
showInfoToast(message);
|
showInfoToast(message);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -375,11 +401,18 @@ async function chooseEvidenceVideo() {
|
|||||||
try {
|
try {
|
||||||
const result = await uni.chooseVideo({
|
const result = await uni.chooseVideo({
|
||||||
sourceType: ["album", "camera"],
|
sourceType: ["album", "camera"],
|
||||||
|
compressed: true,
|
||||||
|
maxDuration: 600,
|
||||||
});
|
});
|
||||||
const filePath = result.tempFilePath;
|
const filePath = result.tempFilePath;
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
const tempFile = result.tempFile as File | undefined;
|
||||||
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id, {
|
||||||
|
original_name: tempFile?.name,
|
||||||
|
file_size: Number(result.size || tempFile?.size || 0),
|
||||||
|
mime_type: tempFile?.type || "video/mp4",
|
||||||
|
});
|
||||||
evidenceFiles.value.push(asset);
|
evidenceFiles.value.push(asset);
|
||||||
showInfoToast("视频上传成功");
|
showInfoToast("视频上传成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -403,7 +436,7 @@ async function chooseZhongjianImage() {
|
|||||||
if (!result.tempFilePaths?.length) return;
|
if (!result.tempFilePaths?.length) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
for (const filePath of result.tempFilePaths) {
|
for (const filePath of result.tempFilePaths) {
|
||||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id, {}, "zhongjian_report");
|
||||||
zhongjianFiles.value.push(asset);
|
zhongjianFiles.value.push(asset);
|
||||||
}
|
}
|
||||||
showInfoToast("图片上传成功");
|
showInfoToast("图片上传成功");
|
||||||
@@ -422,11 +455,23 @@ async function chooseZhongjianVideo() {
|
|||||||
try {
|
try {
|
||||||
const result = await uni.chooseVideo({
|
const result = await uni.chooseVideo({
|
||||||
sourceType: ["album", "camera"],
|
sourceType: ["album", "camera"],
|
||||||
|
compressed: true,
|
||||||
|
maxDuration: 600,
|
||||||
});
|
});
|
||||||
const filePath = result.tempFilePath;
|
const filePath = result.tempFilePath;
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
const tempFile = result.tempFile as File | undefined;
|
||||||
|
const asset = await adminApi.uploadAppraisalEvidenceFile(
|
||||||
|
filePath,
|
||||||
|
detail.value.task_info.id,
|
||||||
|
{
|
||||||
|
original_name: tempFile?.name,
|
||||||
|
file_size: Number(result.size || tempFile?.size || 0),
|
||||||
|
mime_type: tempFile?.type || "video/mp4",
|
||||||
|
},
|
||||||
|
"zhongjian_report",
|
||||||
|
);
|
||||||
zhongjianFiles.value.push(asset);
|
zhongjianFiles.value.push(asset);
|
||||||
showInfoToast("视频上传成功");
|
showInfoToast("视频上传成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -452,6 +497,13 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
showInfoToast("请先填写鉴定结论");
|
showInfoToast("请先填写鉴定结论");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "submit" && !productName.value.trim() && !categoryName.value.trim() && !brandName.value.trim()) {
|
||||||
|
showInfoToast("请先完善物品信息");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "submit" && !validateRequiredTemplatePoints()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : "";
|
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : "";
|
||||||
if (action === "submit" && !qrInput) {
|
if (action === "submit" && !qrInput) {
|
||||||
@@ -487,12 +539,12 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
action,
|
action,
|
||||||
product_info: {
|
product_info: {
|
||||||
category_id: detail.value!.product_info.category_id,
|
category_id: detail.value!.product_info.category_id,
|
||||||
product_name: detail.value!.product_info.product_name,
|
product_name: productName.value.trim(),
|
||||||
category_name: detail.value!.product_info.category_name,
|
category_name: categoryName.value.trim(),
|
||||||
brand_name: detail.value!.product_info.brand_name,
|
brand_name: brandName.value.trim(),
|
||||||
color: detail.value!.product_info.color,
|
color: color.value.trim(),
|
||||||
size_spec: detail.value!.product_info.size_spec,
|
size_spec: sizeSpec.value.trim(),
|
||||||
serial_no: detail.value!.product_info.serial_no,
|
serial_no: serialNo.value.trim(),
|
||||||
},
|
},
|
||||||
result_text: resultText.value.trim(),
|
result_text: resultText.value.trim(),
|
||||||
result_desc: resultDesc.value.trim(),
|
result_desc: resultDesc.value.trim(),
|
||||||
@@ -573,6 +625,9 @@ async function submitZhongjianReport() {
|
|||||||
showInfoToast("请先完善物品信息");
|
showInfoToast("请先完善物品信息");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!validateRequiredTemplatePoints()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!zhongjianFiles.value.length) {
|
if (!zhongjianFiles.value.length) {
|
||||||
showInfoToast("请至少上传 1 个中检报告文件");
|
showInfoToast("请至少上传 1 个中检报告文件");
|
||||||
return;
|
return;
|
||||||
@@ -691,6 +746,16 @@ onShow(() => {
|
|||||||
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
|
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
|
||||||
<view class="card-title">鉴定结论</view>
|
<view class="card-title">鉴定结论</view>
|
||||||
<view class="stack" style="margin-top: 18rpx">
|
<view class="stack" style="margin-top: 18rpx">
|
||||||
|
<view class="card-desc">报告展示信息</view>
|
||||||
|
<input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" />
|
||||||
|
<input v-model="categoryName" class="field" :disabled="isTaskReadonly" placeholder="品类" />
|
||||||
|
<input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" />
|
||||||
|
<view class="meta-grid">
|
||||||
|
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
|
||||||
|
<input v-model="sizeSpec" class="field" :disabled="isTaskReadonly" placeholder="规格 / 尺寸" />
|
||||||
|
</view>
|
||||||
|
<input v-model="serialNo" class="field" :disabled="isTaskReadonly" placeholder="序列号 / 编码" />
|
||||||
|
<view class="card-desc">鉴定结果</view>
|
||||||
<input v-model="resultText" class="field" :disabled="isTaskReadonly" placeholder="结论,例如:正品 / 存疑" />
|
<input v-model="resultText" class="field" :disabled="isTaskReadonly" placeholder="结论,例如:正品 / 存疑" />
|
||||||
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
|
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
|
||||||
<template v-if="showConditionFields">
|
<template v-if="showConditionFields">
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { resolveApiBaseUrl } from "./env";
|
|||||||
|
|
||||||
const BASE_URL = resolveApiBaseUrl().replace(/\/$/, "");
|
const BASE_URL = resolveApiBaseUrl().replace(/\/$/, "");
|
||||||
const REQUEST_TIMEOUT_MS = 15000;
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
const UPLOAD_TIMEOUT_MS = 300000;
|
||||||
|
export const UPLOAD_FILE_SIZE_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||||
|
export const UPLOAD_FILE_SIZE_LIMIT_TEXT = "50MB";
|
||||||
|
|
||||||
type RequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "HEAD";
|
type RequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "HEAD";
|
||||||
|
|
||||||
@@ -31,6 +34,10 @@ function buildNetworkError(error: UniApp.GeneralCallbackResult, fallback = "网
|
|||||||
return new Error("连接服务器超时,请检查网络,或联系管理员确认 API 服务是否正常");
|
return new Error("连接服务器超时,请检查网络,或联系管理员确认 API 服务是否正常");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("413") || normalized.includes("too large") || errMsg.includes("过大")) {
|
||||||
|
return new Error(`上传文件过大,请压缩到 ${UPLOAD_FILE_SIZE_LIMIT_TEXT} 以内后重试`);
|
||||||
|
}
|
||||||
|
|
||||||
if (normalized.includes("abort")) {
|
if (normalized.includes("abort")) {
|
||||||
return new Error("请求已取消");
|
return new Error("请求已取消");
|
||||||
}
|
}
|
||||||
@@ -90,6 +97,9 @@ export function parseUploadResponse<T>(
|
|||||||
try {
|
try {
|
||||||
payload = JSON.parse(response.data) as ApiResponse<T>;
|
payload = JSON.parse(response.data) as ApiResponse<T>;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (response.statusCode === 413) {
|
||||||
|
throw new Error(`上传文件过大,请压缩到 ${UPLOAD_FILE_SIZE_LIMIT_TEXT} 以内后重试`);
|
||||||
|
}
|
||||||
if (response.statusCode >= 500) {
|
if (response.statusCode >= 500) {
|
||||||
throw new Error("服务器上传处理异常,请稍后重试");
|
throw new Error("服务器上传处理异常,请稍后重试");
|
||||||
}
|
}
|
||||||
@@ -117,6 +127,7 @@ export function uploadFile<T>(url: string, filePath: string, formData: Record<st
|
|||||||
name: "file",
|
name: "file",
|
||||||
header: buildAuthHeaders(),
|
header: buildAuthHeaders(),
|
||||||
formData,
|
formData,
|
||||||
|
timeout: UPLOAD_TIMEOUT_MS,
|
||||||
success: (response) => {
|
success: (response) => {
|
||||||
try {
|
try {
|
||||||
resolve(parseUploadResponse<T>(response));
|
resolve(parseUploadResponse<T>(response));
|
||||||
@@ -128,3 +139,27 @@ export function uploadFile<T>(url: string, filePath: string, formData: Record<st
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function uploadDirectFile(uploadUrl: string, filePath: string, formData: Record<string, string | number>) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: uploadUrl,
|
||||||
|
filePath,
|
||||||
|
name: "file",
|
||||||
|
formData,
|
||||||
|
timeout: UPLOAD_TIMEOUT_MS,
|
||||||
|
success: (response) => {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.statusCode === 413) {
|
||||||
|
reject(new Error("上传文件过大,请压缩后重试"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error("OSS 上传失败,请稍后重试"));
|
||||||
|
},
|
||||||
|
fail: (error) => reject(buildNetworkError(error, "OSS 上传失败,请稍后重试")),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user