chore: sync release updates

This commit is contained in:
wushumin
2026-05-22 15:47:23 +08:00
parent be64b8e5b7
commit baef2fb64c
23 changed files with 879 additions and 131 deletions

View File

@@ -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<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> {
list: T[];
total?: number;
@@ -445,8 +495,13 @@ export const adminApi = {
data,
});
},
uploadWarehouseInboundEvidenceFile(filePath: string) {
return uploadFile<AdminFileAsset>("/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<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
@@ -488,8 +543,13 @@ export const adminApi = {
data: { internal_tag_no: internalTagNo },
});
},
uploadWarehouseReturnPackingFile(filePath: string) {
return uploadFile<AdminFileAsset>("/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<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
@@ -530,8 +590,14 @@ export const adminApi = {
data,
});
},
uploadAppraisalEvidenceFile(filePath: string, taskId?: number) {
return uploadFile<AdminFileAsset>("/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<string, string | number> = 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", {

View File

@@ -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(() => {
<template v-else-if="detail">
<view class="hero">
<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>
@@ -261,6 +279,65 @@ onShow(() => {
</template>
<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 {
display: grid;
gap: 16rpx;

View File

@@ -66,6 +66,11 @@ const resultMetaItems = computed(() => {
items.push({ label: "估值", value: [range, valuationDesc].filter(Boolean).join("") || "-" });
}
const externalRemark = textValue(result.external_remark);
if (externalRemark) {
items.push({ label: "备注", value: externalRemark });
}
return items;
});
const evidenceAttachments = computed(() => detail.value?.evidence_attachments || []);

View File

@@ -120,11 +120,18 @@ async function choosePackingVideo() {
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
compressed: true,
maxDuration: 600,
});
const filePath = result.tempFilePath;
if (!filePath) return;
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);
showInfoToast("视频上传成功");
} catch (error) {

View File

@@ -250,11 +250,18 @@ async function chooseInboundVideo() {
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
compressed: true,
maxDuration: 600,
});
const filePath = result.tempFilePath;
if (!filePath) return;
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);
showInfoToast("视频上传成功");
} catch (error) {

View File

@@ -238,13 +238,30 @@ function updateTemplatePoint(index: number, key: "point_value" | "point_remark",
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(
index: number,
key: "point_value" | "point_remark",
event: Event,
event: unknown,
) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
updateTemplatePoint(index, key, target?.value || "");
updateTemplatePoint(index, key, inputEventValue(event));
}
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) {
showInfoToast(message);
setTimeout(() => {
@@ -375,11 +401,18 @@ async function chooseEvidenceVideo() {
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
compressed: true,
maxDuration: 600,
});
const filePath = result.tempFilePath;
if (!filePath) return;
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);
showInfoToast("视频上传成功");
} catch (error) {
@@ -403,7 +436,7 @@ async function chooseZhongjianImage() {
if (!result.tempFilePaths?.length) return;
uploading.value = true;
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);
}
showInfoToast("图片上传成功");
@@ -422,11 +455,23 @@ async function chooseZhongjianVideo() {
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
compressed: true,
maxDuration: 600,
});
const filePath = result.tempFilePath;
if (!filePath) return;
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);
showInfoToast("视频上传成功");
} catch (error) {
@@ -452,6 +497,13 @@ async function submitResult(action: "save" | "submit") {
showInfoToast("请先填写鉴定结论");
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() : "";
if (action === "submit" && !qrInput) {
@@ -487,12 +539,12 @@ async function submitResult(action: "save" | "submit") {
action,
product_info: {
category_id: detail.value!.product_info.category_id,
product_name: detail.value!.product_info.product_name,
category_name: detail.value!.product_info.category_name,
brand_name: detail.value!.product_info.brand_name,
color: detail.value!.product_info.color,
size_spec: detail.value!.product_info.size_spec,
serial_no: detail.value!.product_info.serial_no,
product_name: productName.value.trim(),
category_name: categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
},
result_text: resultText.value.trim(),
result_desc: resultDesc.value.trim(),
@@ -573,6 +625,9 @@ async function submitZhongjianReport() {
showInfoToast("请先完善物品信息");
return;
}
if (!validateRequiredTemplatePoints()) {
return;
}
if (!zhongjianFiles.value.length) {
showInfoToast("请至少上传 1 个中检报告文件");
return;
@@ -691,6 +746,16 @@ onShow(() => {
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
<view class="card-title">鉴定结论</view>
<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="结论,例如:正品 / 存疑" />
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
<template v-if="showConditionFields">

View File

@@ -3,6 +3,9 @@ import { resolveApiBaseUrl } from "./env";
const BASE_URL = resolveApiBaseUrl().replace(/\/$/, "");
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";
@@ -31,6 +34,10 @@ function buildNetworkError(error: UniApp.GeneralCallbackResult, fallback = "网
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")) {
return new Error("请求已取消");
}
@@ -90,6 +97,9 @@ export function parseUploadResponse<T>(
try {
payload = JSON.parse(response.data) as ApiResponse<T>;
} catch {
if (response.statusCode === 413) {
throw new Error(`上传文件过大,请压缩到 ${UPLOAD_FILE_SIZE_LIMIT_TEXT} 以内后重试`);
}
if (response.statusCode >= 500) {
throw new Error("服务器上传处理异常,请稍后重试");
}
@@ -117,6 +127,7 @@ export function uploadFile<T>(url: string, filePath: string, formData: Record<st
name: "file",
header: buildAuthHeaders(),
formData,
timeout: UPLOAD_TIMEOUT_MS,
success: (response) => {
try {
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 上传失败,请稍后重试")),
});
});
}