chore: prepare release build

This commit is contained in:
wushumin
2026-05-16 16:32:56 +08:00
parent dd56e0861b
commit deecb5d33e
28 changed files with 4396 additions and 361 deletions

View File

@@ -104,7 +104,14 @@ export interface AdminWarehouseWorkbenchContext {
operator_name: string;
remark: string;
created_at: string;
inbound_attachments?: AdminFileAsset[];
packing_attachments?: AdminFileAsset[];
}>;
return_verification?: {
verified: boolean;
report_id: number;
report_no: string;
};
next_action?: string;
next_action_text?: string;
}
@@ -244,6 +251,66 @@ export interface AdminOrderWarehouseOption {
supported_category_names: string[];
}
export interface AdminManualOrderMaterialItem {
item_code: string;
item_name: string;
is_required: boolean;
files: AdminFileAsset[];
}
export interface AdminManualOrderCreatePayload {
service_provider: string;
product_info: {
category_id: number;
brand_id: number;
product_name: string;
color: string;
size_spec: string;
serial_no: string;
};
extra_info: {
purchase_channel: string;
purchase_price: number;
usage_status: string;
condition_desc: string;
remark: string;
};
return_address: {
consignee: string;
mobile: string;
province: string;
city: string;
district: string;
detail_address: string;
};
materials: AdminManualOrderMaterialItem[];
}
export interface AdminManualOrderCreateResponse {
order_id: number;
order_no: string;
appraisal_no: string;
user_id: number;
next_status: "pending_shipping";
}
export interface AdminManualOrderMeta {
categories: Array<{
id: number;
name: string;
code: string;
supported_service_types: string[];
}>;
brands: Array<{
id: number;
name: string;
en_name: string;
code: string;
category_ids: number[];
supported_service_types: string[];
}>;
}
export interface CatalogOverviewCard {
title: string;
value: number;
@@ -380,6 +447,9 @@ export interface AdminReportListItem {
product_name: string;
category_name: string;
brand_name: string;
material_tag_bound: boolean;
material_tag_verify_code: string;
material_tag_bind_status: string;
}
export interface AdminReportDetail {
@@ -414,6 +484,7 @@ export interface AdminReportDetail {
mime_type?: string;
}>;
zhongjian_report_files: AdminFileAsset[];
material_tag: null | AdminMaterialTagCode;
risk_notice_text: string;
verify_info: {
verify_status: string;
@@ -705,6 +776,7 @@ export interface AdminAppraisalTaskResultPayload {
}>;
external_remark: string;
internal_remark: string;
qr_input?: string;
}
export interface AdminAppraisalTaskSupplementPayload {
@@ -1450,6 +1522,33 @@ export const adminApi = {
};
}>;
},
createManualOrder(data: AdminManualOrderCreatePayload) {
return request.post("/api/admin/manual-order/create", data) as Promise<{
code: number;
message: string;
data: AdminManualOrderCreateResponse;
}>;
},
getManualOrderMeta() {
return request.get("/api/admin/manual-order/meta") as Promise<{
code: number;
message: string;
data: AdminManualOrderMeta;
}>;
},
uploadManualOrderFile(file: File) {
const formData = new FormData();
formData.append("file", file);
return request.post("/api/admin/manual-order/file/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
}) as Promise<{
code: number;
message: string;
data: AdminFileAsset;
}>;
},
getCatalogOverview() {
return request.get("/api/admin/catalog/overview") as Promise<{
code: number;
@@ -1588,13 +1687,14 @@ export const adminApi = {
data: AdminReportDetail;
}>;
},
publishReport(id: number) {
publishReport(id: number, qrInput = "") {
return request.post("/api/admin/report/publish", {
id,
qr_input: qrInput,
}) as Promise<{
code: number;
message: string;
data: AdminPublishReportResponse;
data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null };
}>;
},
saveInspectionReport(data: AdminManualInspectionPayload) {
@@ -1689,7 +1789,7 @@ export const adminApi = {
};
}>;
},
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[]; qr_input: string }) {
return request.post("/api/admin/appraisal-task/zhongjian-report/save", data) as Promise<{
code: number;
message: string;
@@ -1728,16 +1828,16 @@ export const adminApi = {
data: { file_url: string };
}>;
},
lookupWarehouseInbound(trackingNo: string) {
lookupWarehouseInbound(inboundNo: string) {
return request.get("/api/admin/warehouse-workbench/inbound/lookup", {
params: { tracking_no: trackingNo },
params: { inbound_no: inboundNo },
}) as Promise<{
code: number;
message: string;
data: AdminWarehouseWorkbenchContext;
}>;
},
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
receiveWarehouseInbound(data: { inbound_no?: string; tracking_no?: string; internal_tag_no: string; inbound_attachments?: AdminFileAsset[] }) {
return request.post("/api/admin/warehouse-workbench/inbound/receive", data) as Promise<{
code: number;
message: string;
@@ -1775,12 +1875,32 @@ export const adminApi = {
data: AdminWarehouseWorkbenchContext;
}>;
},
confirmWarehouseReturnReport(data: { internal_tag_no: string; report_id: number }) {
return request.post("/api/admin/warehouse-workbench/return/report/confirm", data) as Promise<{
code: number;
message: string;
data: AdminWarehouseWorkbenchContext;
}>;
},
confirmWarehouseReturnZhongjian(internalTagNo: string) {
return request.post("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
internal_tag_no: internalTagNo,
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
},
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string }) {
uploadWarehouseReturnPackingFile(file: File) {
const formData = new FormData();
formData.append("file", file);
return request.post("/api/admin/warehouse-workbench/return/packing/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
}) as Promise<{
code: number;
message: string;
data: AdminFileAsset;
}>;
},
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) {
return request.post("/api/admin/warehouse-workbench/return/ship", data) as Promise<{
code: number;
message: string;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, type InputInstance } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import {
adminApi,
type AdminFileAsset,
@@ -27,10 +27,6 @@ const evidenceUploading = ref(false);
const appraisalTemplateLoading = ref(false);
const transferTagNo = ref("");
const transferScanLoading = ref(false);
const publishDialogVisible = ref(false);
const publishMaterialTagInput = ref("");
const publishMaterialTagInputRef = ref<InputInstance | null>(null);
const publishMaterialTagSubmitting = ref(false);
const zhongjianReportNo = ref("");
const zhongjianReportFiles = ref<AdminFileAsset[]>([]);
const zhongjianReportUploading = ref(false);
@@ -277,14 +273,10 @@ const canBindMaterialTag = computed(() => {
if (!detail.value?.report_summary) {
return false;
}
if (detail.value.task_info.service_provider === "zhongjian") {
return false;
}
return detail.value.report_summary.report_status !== "published" && !detail.value.material_tag;
});
const isZhongjianTask = computed(() => detail.value?.task_info.service_provider === "zhongjian");
const isPhysicalTask = computed(() => Boolean(detail.value) && !isZhongjianTask.value);
const canRequestSupplement = computed(() => detail.value?.task_info.status !== "completed");
const currentAdmin = computed(() => getAdminInfo());
const canClaimTask = computed(() => {
@@ -783,6 +775,13 @@ async function submitResult(action: "save" | "submit") {
if (action === "submit" && !validateRequiredKeyPoints()) {
return;
}
let qrInput = "";
if (action === "submit") {
qrInput = await promptPublishMaterialTagInput();
if (!qrInput) {
return;
}
}
resultSubmitting.value = true;
try {
const response = await adminApi.saveAppraisalTaskResult({
@@ -792,16 +791,11 @@ async function submitResult(action: "save" | "submit") {
...resultForm,
attachments: resultAttachments.value,
key_points: normalizedKeyPoints(),
...(qrInput ? { qr_input: qrInput } : {}),
});
ElMessage.success(response.message || (action === "submit" ? "结论已提交" : "结论已保存"));
ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告已发布" : "结论已保存"));
await loadDetail(detail.value.task_info.id);
await fetchTasks();
if (action === "submit") {
publishMaterialTagInput.value = "";
publishDialogVisible.value = true;
await nextTick();
publishMaterialTagInputRef.value?.focus();
}
} catch (error) {
console.error(error);
ElMessage.error(action === "submit" ? "结论提交失败" : "结论保存失败");
@@ -812,10 +806,6 @@ async function submitResult(action: "save" | "submit") {
async function publishCurrentTaskWithMaterialTag(qrInput: string) {
if (!detail.value) return;
if (!isPhysicalTask.value) {
ElMessage.warning("中检订单不使用平台验真吊牌");
return;
}
await adminApi.publishAppraisalTaskWithMaterialTag({
id: detail.value.task_info.id,
@@ -845,32 +835,23 @@ async function bindMaterialTag() {
}
}
async function publishDialogMaterialTag() {
const qrInput = publishMaterialTagInput.value.trim();
if (!qrInput) {
ElMessage.warning("请扫描验真吊牌二维码");
return;
}
publishMaterialTagSubmitting.value = true;
async function promptPublishMaterialTagInput() {
try {
await publishCurrentTaskWithMaterialTag(qrInput);
publishDialogVisible.value = false;
publishMaterialTagInput.value = "";
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败");
} finally {
publishMaterialTagSubmitting.value = false;
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告?", "绑定验真吊牌并发布报告", {
type: "warning",
inputPlaceholder: "请扫描验真吊牌二维码",
inputPattern: /\S+/,
inputErrorMessage: "请扫描验真吊牌二维码",
confirmButtonText: "是的,去绑定验真吊牌",
cancelButtonText: "取消",
closeOnClickModal: false,
});
return String(result.value || "").trim();
} catch {
return "";
}
}
function focusPublishMaterialTagInput() {
nextTick(() => {
publishMaterialTagInputRef.value?.focus();
});
}
async function submitZhongjianReport() {
if (!detail.value) return;
if (!isZhongjianTask.value) {
@@ -885,6 +866,10 @@ async function submitZhongjianReport() {
ElMessage.warning("请至少上传 1 个中检报告文件");
return;
}
const qrInput = await promptPublishMaterialTagInput();
if (!qrInput) {
return;
}
zhongjianReportSubmitting.value = true;
try {
@@ -892,8 +877,9 @@ async function submitZhongjianReport() {
id: detail.value.task_info.id,
zhongjian_report_no: zhongjianReportNo.value.trim(),
report_files: zhongjianReportFiles.value,
qr_input: qrInput,
});
ElMessage.success(response.message || "中检报告已录入并发布");
ElMessage.success(response.message || "验真吊牌已绑定,报告已发布");
await loadDetail(detail.value.task_info.id);
await fetchTasks();
} catch (error: any) {
@@ -1428,7 +1414,7 @@ onMounted(async () => {
<div :key="`result-${formRenderKey}`" class="task-form-stack">
<el-alert
v-if="isZhongjianTask"
title="中检订单不走平台验真吊牌流程,请切换到中检报告录入。"
title="中检订单请在中检报告录入页提交,提交时同样需要绑定验真吊牌。"
type="info"
:closable="false"
show-icon
@@ -1476,16 +1462,8 @@ onMounted(async () => {
<div class="task-form-block">
<div class="task-form-block__title">吊牌绑定</div>
<div class="task-panel__desc">实物鉴定提交结论后扫描平台验真吊牌绑定后发布报告</div>
<el-alert
v-if="isZhongjianTask"
title="中检订单不使用本平台验真吊牌。"
type="warning"
:closable="false"
show-icon
style="margin-top: 12px;"
/>
<div v-else-if="detail.material_tag" class="task-material-tag-bound">
<div class="task-panel__desc">提交结论或中检报告时扫描平台验真吊牌绑定成功后发布报告</div>
<div v-if="detail.material_tag" class="task-material-tag-bound">
<div class="task-info-grid">
<div class="task-info-item task-info-item--full">
<div class="task-info-item__label">二维码链接</div>
@@ -1673,7 +1651,7 @@ onMounted(async () => {
<el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian">
<div :key="`zhongjian-${formRenderKey}`" class="task-form-stack">
<el-alert
title="中检订单不绑定平台验真吊牌,提交中检报告编号和文件后直接发布报告。"
title="提交中检报告编号和文件后,需要扫描平台验真吊牌;绑定成功后才会发布报告。"
type="info"
:closable="false"
show-icon
@@ -1836,33 +1814,6 @@ onMounted(async () => {
</template>
</el-dialog>
<el-dialog
v-model="publishDialogVisible"
title="绑定验真吊牌并发布报告"
width="560px"
@opened="focusPublishMaterialTagInput"
>
<div class="publish-dialog-body">
<el-alert
title="请扫描物品验真吊牌二维码,回车后发布正式报告。"
type="info"
:closable="false"
show-icon
/>
<el-input
ref="publishMaterialTagInputRef"
v-model="publishMaterialTagInput"
size="large"
placeholder="扫描平台验真吊牌二维码"
clearable
@keyup.enter="publishDialogMaterialTag"
/>
</div>
<template #footer>
<el-button @click="publishDialogVisible = false">稍后处理</el-button>
<el-button type="primary" :loading="publishMaterialTagSubmitting" @click="publishDialogMaterialTag">完成并发布报告</el-button>
</template>
</el-dialog>
</template>
<style scoped>
@@ -1895,11 +1846,6 @@ onMounted(async () => {
min-width: 0;
}
.publish-dialog-body {
display: grid;
gap: 16px;
}
:deep(.task-detail-drawer .el-drawer__body) {
display: flex;
flex-direction: column;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
import { adminApi, type AdminFileAsset, type AdminManualOrderCreatePayload, type AdminManualOrderMeta, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
@@ -18,6 +18,12 @@ const returnDialogVisible = ref(false);
const returnSubmitting = ref(false);
const returnExpressCompany = ref("");
const returnTrackingNo = ref("");
const manualDialogVisible = ref(false);
const manualSubmitting = ref(false);
const manualMetaLoading = ref(false);
const manualUploading = ref(false);
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
const keyword = ref("");
const serviceProvider = ref("");
@@ -48,6 +54,7 @@ const sourceChannelOptions = [
{ label: "小程序", value: "mini_program" },
{ label: "H5", value: "h5" },
{ label: "大客户推送订单", value: "enterprise_push" },
{ label: "后台补录订单", value: "manual_entry" },
];
const usageStatusMap: Record<string, string> = {
@@ -107,6 +114,52 @@ const logisticsActionText = computed(() => {
const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics));
const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || "");
const canMarkReturnReceived = computed(() => Boolean(detail.value?.order_info.can_mark_return_received));
const manualBrandOptions = computed(() => {
const categoryId = manualForm.value.product_info.category_id;
const provider = manualForm.value.service_provider;
return manualMeta.value.brands.filter((item) => {
const categoryMatched = !categoryId || !item.category_ids.length || item.category_ids.includes(categoryId);
const providerMatched = !item.supported_service_types.length || item.supported_service_types.includes(provider);
return categoryMatched && providerMatched;
});
});
function createManualOrderForm(): AdminManualOrderCreatePayload {
return {
service_provider: "anxinyan",
product_info: {
category_id: 0,
brand_id: 0,
product_name: "",
color: "",
size_spec: "",
serial_no: "",
},
extra_info: {
purchase_channel: "",
purchase_price: 0,
usage_status: "",
condition_desc: "",
remark: "",
},
return_address: {
consignee: "",
mobile: "",
province: "",
city: "",
district: "",
detail_address: "",
},
materials: [
{
item_code: "manual_initial",
item_name: "补录资料",
is_required: false,
files: [],
},
],
};
}
async function fetchOrders() {
loading.value = true;
@@ -126,6 +179,82 @@ async function fetchOrders() {
}
}
async function ensureManualMeta() {
if (manualMeta.value.categories.length && manualMeta.value.brands.length) return;
manualMetaLoading.value = true;
try {
const response = await adminApi.getManualOrderMeta();
manualMeta.value = response.data;
} catch (error) {
console.error(error);
ElMessage.error("补录订单选项加载失败");
} finally {
manualMetaLoading.value = false;
}
}
async function openManualDialog() {
manualForm.value = createManualOrderForm();
manualDialogVisible.value = true;
await ensureManualMeta();
}
function handleManualCategoryChange() {
const selectedBrand = manualBrandOptions.value.find((item) => item.id === manualForm.value.product_info.brand_id);
if (!selectedBrand) {
manualForm.value.product_info.brand_id = 0;
}
}
function validateManualForm() {
const form = manualForm.value;
if (!form.product_info.category_id || !form.product_info.brand_id || !form.product_info.product_name.trim()) {
ElMessage.warning("请完整填写品类、品牌和商品名称");
return false;
}
const address = form.return_address;
if (!address.consignee.trim() || !address.mobile.trim() || !address.province.trim() || !address.city.trim() || !address.district.trim() || !address.detail_address.trim()) {
ElMessage.warning("请完整填写寄回收件信息");
return false;
}
return true;
}
async function uploadManualMaterial(options: { file: File }) {
manualUploading.value = true;
try {
const response = await adminApi.uploadManualOrderFile(options.file);
manualForm.value.materials[0].files.push(response.data);
ElMessage.success("资料已上传");
} catch (error) {
console.error(error);
ElMessage.error(error instanceof Error ? error.message : "资料上传失败");
} finally {
manualUploading.value = false;
}
}
function removeManualMaterial(file: AdminFileAsset) {
manualForm.value.materials[0].files = manualForm.value.materials[0].files.filter((item) => item.file_url !== file.file_url);
}
async function submitManualOrder() {
if (!validateManualForm()) return;
manualSubmitting.value = true;
try {
const payload: AdminManualOrderCreatePayload = JSON.parse(JSON.stringify(manualForm.value));
const response = await adminApi.createManualOrder(payload);
ElMessage.success(`补录订单已创建:${response.data.order_no} / ${response.data.appraisal_no}`);
manualDialogVisible.value = false;
await fetchOrders();
} catch (error) {
console.error(error);
ElMessage.error(error instanceof Error ? error.message : "补录订单创建失败");
} finally {
manualSubmitting.value = false;
}
}
async function openDetail(row: AdminOrderListItem) {
detailLoading.value = true;
drawerVisible.value = true;
@@ -289,6 +418,7 @@ onMounted(fetchOrders);
<el-option v-for="item in sourceChannelOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="fetchOrders">查询</el-button>
<el-button @click="openManualDialog">补录订单</el-button>
</div>
</el-card>
@@ -665,6 +795,124 @@ onMounted(fetchOrders);
<el-button type="primary" :loading="returnSubmitting" :disabled="!canSubmitReturnLogistics" @click="submitReturnLogistics">确认登记</el-button>
</template>
</el-dialog>
<el-dialog v-model="manualDialogVisible" title="补录订单" width="860px" destroy-on-close>
<div v-loading="manualMetaLoading" class="manual-order-form">
<el-alert
type="info"
:closable="false"
show-icon
title="补录订单创建后为待入库状态"
description="创建成功后,可在入库台使用订单号或鉴定单号匹配并绑定内部流转挂牌,不需要填写寄入快递单号。"
/>
<div class="manual-section">
<div class="manual-section__title">订单与商品</div>
<el-form label-position="top">
<div class="manual-grid">
<el-form-item label="服务类型">
<el-select v-model="manualForm.service_provider" style="width: 100%">
<el-option label="实物鉴定" value="anxinyan" />
<el-option label="中检鉴定" value="zhongjian" />
</el-select>
</el-form-item>
<el-form-item label="品类">
<el-select v-model="manualForm.product_info.category_id" filterable style="width: 100%" @change="handleManualCategoryChange">
<el-option v-for="item in manualMeta.categories" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="品牌">
<el-select v-model="manualForm.product_info.brand_id" filterable style="width: 100%">
<el-option v-for="item in manualBrandOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="商品名称">
<el-input v-model="manualForm.product_info.product_name" placeholder="例如Classic Flap 手袋" />
</el-form-item>
<el-form-item label="颜色">
<el-input v-model="manualForm.product_info.color" placeholder="可选" />
</el-form-item>
<el-form-item label="规格 / 尺寸">
<el-input v-model="manualForm.product_info.size_spec" placeholder="可选" />
</el-form-item>
<el-form-item label="序列号">
<el-input v-model="manualForm.product_info.serial_no" placeholder="可选" />
</el-form-item>
<el-form-item label="购买渠道">
<el-input v-model="manualForm.extra_info.purchase_channel" placeholder="可选" />
</el-form-item>
<el-form-item label="购买价格">
<el-input-number v-model="manualForm.extra_info.purchase_price" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="使用情况">
<el-select v-model="manualForm.extra_info.usage_status" clearable style="width: 100%">
<el-option label="全新未使用" value="new" />
<el-option label="轻微使用痕迹" value="light_use" />
<el-option label="长期使用" value="used" />
</el-select>
</el-form-item>
</div>
<el-form-item label="成色说明">
<el-input v-model="manualForm.extra_info.condition_desc" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
<el-form-item label="内部备注">
<el-input v-model="manualForm.extra_info.remark" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</el-form>
</div>
<div class="manual-section">
<div class="manual-section__title">寄回信息</div>
<el-form label-position="top">
<div class="manual-grid">
<el-form-item label="收件人">
<el-input v-model="manualForm.return_address.consignee" placeholder="用于匹配或创建用户" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="manualForm.return_address.mobile" placeholder="按手机号复用已有用户" />
</el-form-item>
<el-form-item label="省份">
<el-input v-model="manualForm.return_address.province" placeholder="例如:广东省" />
</el-form-item>
<el-form-item label="城市">
<el-input v-model="manualForm.return_address.city" placeholder="例如:深圳市" />
</el-form-item>
<el-form-item label="区县">
<el-input v-model="manualForm.return_address.district" placeholder="例如:南山区" />
</el-form-item>
<el-form-item label="详细地址">
<el-input v-model="manualForm.return_address.detail_address" placeholder="街道、门牌号" />
</el-form-item>
</div>
</el-form>
</div>
<div class="manual-section">
<div class="manual-section__title">初始资料</div>
<div class="manual-upload-head">
<el-upload
:show-file-list="false"
:http-request="uploadManualMaterial"
:disabled="manualUploading"
multiple
>
<el-button :loading="manualUploading">上传图片/视频/PDF</el-button>
</el-upload>
<span class="manual-upload-hint">{{ manualForm.materials[0].files.length }} 个资料文件</span>
</div>
<div v-if="manualForm.materials[0].files.length" class="manual-file-list">
<div v-for="file in manualForm.materials[0].files" :key="file.file_url" class="manual-file-item">
<span>{{ file.name || file.file_url }}</span>
<el-button link type="danger" @click="removeManualMaterial(file)">移除</el-button>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="manualDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="manualSubmitting" :disabled="manualUploading || manualMetaLoading" @click="submitManualOrder">创建补录订单</el-button>
</template>
</el-dialog>
</template>
<style scoped>
@@ -789,6 +1037,65 @@ onMounted(fetchOrders);
word-break: break-word;
}
.manual-order-form {
display: grid;
gap: 18px;
}
.manual-section {
display: grid;
gap: 14px;
}
.manual-section__title {
color: var(--admin-text-main);
font-size: 16px;
font-weight: 800;
}
.manual-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
}
.manual-upload-head {
display: flex;
align-items: center;
gap: 14px;
}
.manual-upload-hint {
color: var(--admin-text-subtle);
font-size: 13px;
}
.manual-file-list {
display: grid;
gap: 10px;
}
.manual-file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: #fffdfa;
}
.manual-file-item span {
min-width: 0;
overflow: hidden;
color: var(--admin-text-main);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1280px) {
.order-detail-hero {
grid-template-columns: 1fr;
@@ -810,5 +1117,9 @@ onMounted(fetchOrders);
.order-detail-grid {
grid-template-columns: 1fr;
}
.manual-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -273,25 +273,58 @@ async function openDetailFromRouteQuery() {
await loadDetail(reportId);
}
async function publishReport(row: Pick<AdminReportListItem, "id" | "report_status"> | { id: number; report_status: string }) {
type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "report_type" | "material_tag_bound"> | {
id: number;
report_status: string;
report_type: string;
material_tag_bound: boolean;
};
async function promptReportMaterialTagInput() {
try {
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告?", "绑定验真吊牌并发布报告", {
type: "warning",
inputPlaceholder: "请扫描验真吊牌二维码",
inputPattern: /\S+/,
inputErrorMessage: "请扫描验真吊牌二维码",
confirmButtonText: "是的,去绑定验真吊牌",
cancelButtonText: "取消",
closeOnClickModal: false,
});
return String(result.value || "").trim();
} catch {
return "";
}
}
async function publishReport(row: PublishReportTarget) {
if (row.report_status !== "pending_publish") {
ElMessage.warning("仅待发布报告可以执行发布");
return;
}
try {
await ElMessageBox.confirm("发布后用户端将可查看正式报告并进行验真,是否继续?", "发布报告", {
type: "warning",
confirmButtonText: "确认发布",
cancelButtonText: "取消",
});
} catch {
return;
const needMaterialTag = row.report_type !== "inspection" && !row.material_tag_bound;
let qrInput = "";
if (needMaterialTag) {
qrInput = await promptReportMaterialTagInput();
if (!qrInput) {
return;
}
} else {
try {
await ElMessageBox.confirm("发布后用户端将可查看正式报告并进行验真,是否继续?", "发布报告", {
type: "warning",
confirmButtonText: "确认发布",
cancelButtonText: "取消",
});
} catch {
return;
}
}
publishingId.value = row.id;
try {
const response = await adminApi.publishReport(row.id);
const response = await adminApi.publishReport(row.id, qrInput);
if (response.code !== 0) {
ElMessage.error(response.message || "报告发布失败");
return;
@@ -427,6 +460,12 @@ watch(
<OrderStatusTag :status="row.report_status_text" />
</template>
</el-table-column>
<el-table-column label="验真吊牌" min-width="120">
<template #default="{ row }">
<OrderStatusTag v-if="row.report_type !== 'inspection'" :status="row.material_tag_bound ? '已绑定' : '未绑定'" />
<span v-else class="detail-label">不适用</span>
</template>
</el-table-column>
<el-table-column prop="institution_name" label="出具机构" min-width="160" />
<el-table-column prop="publish_time" label="发布时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="220">
@@ -464,7 +503,12 @@ watch(
v-if="canPublishCurrentReport"
type="primary"
:loading="publishingId === detail.report_header.id"
@click="publishReport({ id: detail.report_header.id, report_status: detail.report_header.report_status })"
@click="publishReport({
id: detail.report_header.id,
report_status: detail.report_header.report_status,
report_type: detail.report_header.report_type,
material_tag_bound: Boolean(detail.material_tag),
})"
>
发布报告
</el-button>
@@ -496,6 +540,32 @@ watch(
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">验真吊牌</div>
<template v-if="detail.report_header.report_type === 'inspection'">
<div class="detail-card__desc">
<div class="detail-value">补录检查单不需要绑定验真吊牌</div>
</div>
</template>
<template v-else-if="detail.material_tag">
<div class="detail-card__desc">
<div class="detail-label">二维码链接</div>
<div class="detail-value" style="word-break: break-all;">{{ detail.material_tag.qr_url }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">验真编码</div>
<div class="detail-value">{{ detail.material_tag.verify_code }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">绑定时间</div>
<div class="detail-value">{{ detail.material_tag.bound_at || "-" }}</div>
</div>
</template>
<div v-else class="detail-card__desc">
<div class="detail-value">未绑定发布前需要扫描验真吊牌</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">商品信息</div>
<div class="detail-card__desc">
@@ -652,7 +722,7 @@ watch(
</div>
</div>
<div v-if="detail.report_header.service_provider !== 'zhongjian'" class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">扫码与公开链接</div>
<div style="display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start;">
<div

View File

@@ -1,7 +1,12 @@
<script setup lang="ts">
import { computed, defineComponent, h, nextTick, ref, type PropType } from "vue";
import { ElMessage, type InputInstance } from "element-plus";
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
import {
adminApi,
type AdminFileAsset,
type AdminReportDetail,
type AdminWarehouseWorkbenchContext,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const activeMode = ref<"inbound" | "zhongjian" | "return">("inbound");
@@ -15,17 +20,31 @@ const returnTagNo = ref("");
const returnMaterialQr = ref("");
const returnExpressCompany = ref("");
const returnTrackingNo = ref("");
const returnPackingAttachments = ref<AdminFileAsset[]>([]);
const inboundContext = ref<AdminWarehouseWorkbenchContext | null>(null);
const zhongjianContext = ref<AdminWarehouseWorkbenchContext | null>(null);
const returnContext = ref<AdminWarehouseWorkbenchContext | null>(null);
const returnReviewReport = ref<AdminReportDetail | null>(null);
const inboundTagInputRef = ref<InputInstance | null>(null);
const returnMaterialInputRef = ref<InputInstance | null>(null);
const returnTrackingInputRef = ref<InputInstance | null>(null);
const returnReviewDrawerVisible = ref(false);
const returnReviewLoading = ref(false);
const returnConfirmLoading = ref(false);
const returnPackingUploading = ref(false);
const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian");
const returnConfirmed = computed(() => Boolean(returnContext.value?.transfer_flow?.return_confirmed_at));
const returnMaterialMatched = computed(() => Boolean(returnContext.value?.return_verification?.verified));
const returnReviewReportId = computed(() => Number(returnContext.value?.report_info?.id || returnContext.value?.return_verification?.report_id || 0));
const returnReportActionText = computed(() => {
if (returnConfirmed.value) return "报告已确认";
if (currentReturnIsZhongjian.value || returnMaterialMatched.value) return "核对报告";
return "匹配吊牌并核对报告";
});
const OrderContextCard = defineComponent({
name: "OrderContextCard",
@@ -37,6 +56,23 @@ const OrderContextCard = defineComponent({
},
emits: ["open-file"],
setup(props, { emit }) {
const renderFileButtons = (title: string, files?: AdminFileAsset[]) => {
if (!files?.length) {
return null;
}
return h("div", { class: "flow-log-files" }, [
h("div", { class: "flow-log-files__title" }, title),
h(
"div",
{ class: "file-list" },
files.map((file) =>
h("button", { class: "file-button", type: "button", onClick: () => emit("open-file", file.file_url) }, file.name || file.file_url),
),
),
]);
};
return () => {
if (!props.context) {
return h("div", { class: "detail-card empty-context" }, "等待扫码识别订单");
@@ -97,6 +133,8 @@ const OrderContextCard = defineComponent({
]),
h("div", { class: "flow-log-item__meta" }, `${log.operator_name || "系统"} / ${log.after_stage || "-"} / ${log.after_location || "-"}`),
log.remark ? h("div", { class: "flow-log-item__remark" }, log.remark) : null,
renderFileButtons("入库附件", log.inbound_attachments),
renderFileButtons("装箱附件", log.packing_attachments),
]),
),
),
@@ -114,7 +152,7 @@ function resetMode(mode: typeof activeMode.value) {
async function lookupInbound() {
const trackingNo = inboundTrackingNo.value.trim();
if (!trackingNo) {
ElMessage.warning("请扫描寄入运单号");
ElMessage.warning("请扫描快递单号或输入鉴定订单号");
return;
}
loading.value = true;
@@ -126,7 +164,7 @@ async function lookupInbound() {
inboundTagInputRef.value?.focus();
} catch (error: any) {
inboundContext.value = null;
ElMessage.error(error?.message || "未匹配到订单");
ElMessage.error(error?.message || "未匹配到待入库订单");
} finally {
loading.value = false;
}
@@ -144,7 +182,7 @@ async function receiveInbound() {
actionLoading.value = true;
try {
const response = await adminApi.receiveWarehouseInbound({
tracking_no: inboundTrackingNo.value.trim(),
inbound_no: inboundTrackingNo.value.trim(),
internal_tag_no: inboundTagNo.value.trim(),
});
inboundContext.value = response.data;
@@ -205,13 +243,19 @@ async function lookupReturn() {
}
loading.value = true;
try {
returnMaterialQr.value = "";
returnExpressCompany.value = "";
returnTrackingNo.value = "";
returnPackingAttachments.value = [];
returnReviewReport.value = null;
returnReviewDrawerVisible.value = false;
const response = await adminApi.lookupWarehouseReturn(returnTagNo.value.trim());
returnContext.value = response.data;
ElMessage.success("已打开待寄回订单");
await nextTick();
if (response.data.order_info.service_provider === "zhongjian") {
if (response.data.transfer_flow?.return_confirmed_at) {
returnTrackingInputRef.value?.focus();
} else {
} else if (response.data.order_info.service_provider !== "zhongjian") {
returnMaterialInputRef.value?.focus();
}
} catch (error: any) {
@@ -222,30 +266,119 @@ async function lookupReturn() {
}
}
async function confirmReturnReport() {
async function openReturnReportReview() {
const reportId = returnReviewReportId.value;
if (!reportId) {
ElMessage.warning("未找到可核对的报告");
return;
}
returnReviewDrawerVisible.value = true;
returnReviewLoading.value = true;
returnReviewReport.value = null;
try {
const response = await adminApi.getReportDetail(reportId);
if (response.code !== 0) {
ElMessage.error(response.message || "报告详情加载失败");
return;
}
returnReviewReport.value = response.data;
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "报告详情加载失败");
} finally {
returnReviewLoading.value = false;
}
}
async function handleReturnReportStep() {
if (!returnContext.value) {
await lookupReturn();
return;
}
actionLoading.value = true;
try {
const response = currentReturnIsZhongjian.value
? await adminApi.confirmWarehouseReturnZhongjian(returnTagNo.value.trim())
: await adminApi.verifyWarehouseReturnMaterialTag({
internal_tag_no: returnTagNo.value.trim(),
qr_input: returnMaterialQr.value.trim(),
});
returnContext.value = response.data;
ElMessage.success(currentReturnIsZhongjian.value ? "中检报告已确认" : "验真吊牌已确认");
if (returnConfirmed.value) {
ElMessage.success("报告已确认,请填写回寄信息");
await nextTick();
returnTrackingInputRef.value?.focus();
return;
}
if (!currentReturnIsZhongjian.value && !returnMaterialMatched.value && !returnMaterialQr.value.trim()) {
ElMessage.warning("请扫描或填写平台验真吊牌链接");
return;
}
actionLoading.value = true;
try {
if (!currentReturnIsZhongjian.value && !returnMaterialMatched.value) {
const response = await adminApi.verifyWarehouseReturnMaterialTag({
internal_tag_no: returnTagNo.value.trim(),
qr_input: returnMaterialQr.value.trim(),
});
returnContext.value = response.data;
ElMessage.success(response.message || "验真吊牌匹配通过,请核对报告");
}
await openReturnReportReview();
} catch (error: any) {
ElMessage.error(error?.message || "报告确认失败");
ElMessage.error(error?.message || "报告核对失败");
} finally {
actionLoading.value = false;
}
}
async function confirmReturnReview() {
if (!returnReviewReport.value) {
ElMessage.warning("请先加载报告详情");
return;
}
returnConfirmLoading.value = true;
try {
const response = await adminApi.confirmWarehouseReturnReport({
internal_tag_no: returnTagNo.value.trim(),
report_id: returnReviewReport.value.report_header.id,
});
if (response.code !== 0) {
ElMessage.error(response.message || "报告确认失败");
return;
}
returnContext.value = response.data;
returnReviewDrawerVisible.value = false;
ElMessage.success(response.message || "报告已确认,可填写回寄运单");
await nextTick();
returnTrackingInputRef.value?.focus();
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "报告确认失败");
} finally {
returnConfirmLoading.value = false;
}
}
async function uploadReturnPackingAttachment(options: { file: File }) {
returnPackingUploading.value = true;
try {
const response = await adminApi.uploadWarehouseReturnPackingFile(options.file);
if (response.code !== 0) {
ElMessage.error(response.message || "装箱附件上传失败");
return;
}
returnPackingAttachments.value.push(response.data);
ElMessage.success("装箱附件已上传");
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "装箱附件上传失败");
} finally {
returnPackingUploading.value = false;
}
}
function removeReturnPackingAttachment(fileUrl: string) {
returnPackingAttachments.value = returnPackingAttachments.value.filter((item) => item.file_url !== fileUrl);
}
function fileTypeText(file: AdminFileAsset) {
return file.file_type === "image" ? "图片" : file.file_type === "video" ? "视频" : "附件";
}
async function shipReturn() {
if (!returnContext.value) {
await lookupReturn();
@@ -259,14 +392,20 @@ async function shipReturn() {
ElMessage.warning("请填写回寄快递公司和运单号");
return;
}
if (returnPackingUploading.value) {
ElMessage.warning("装箱附件上传中,请稍后提交");
return;
}
actionLoading.value = true;
try {
const response = await adminApi.shipWarehouseReturn({
internal_tag_no: returnTagNo.value.trim(),
express_company: returnExpressCompany.value.trim(),
tracking_no: returnTrackingNo.value.trim(),
packing_attachments: returnPackingAttachments.value,
});
returnContext.value = response.data;
returnPackingAttachments.value = [];
ElMessage.success("回寄运单已登记");
} catch (error: any) {
ElMessage.error(error?.message || "回寄失败");
@@ -299,7 +438,7 @@ function openFile(url: string) {
<el-card class="panel-card" shadow="never">
<template #header>入库扫描</template>
<div class="scan-stack">
<el-input v-model="inboundTrackingNo" size="large" placeholder="扫描寄入快递运单号" clearable @keyup.enter="lookupInbound" />
<el-input v-model="inboundTrackingNo" size="large" placeholder="扫描快递单号 / 输入鉴定订单号" clearable @keyup.enter="lookupInbound" />
<el-input ref="inboundTagInputRef" v-model="inboundTagNo" size="large" placeholder="扫描内部流转挂牌" clearable @keyup.enter="receiveInbound" />
<div class="actions-row">
<el-button type="primary" :loading="loading" @click="lookupInbound">匹配订单</el-button>
@@ -332,37 +471,215 @@ function openFile(url: string) {
<div class="scan-stack">
<el-input v-model="returnTagNo" size="large" placeholder="扫描内部流转码" clearable @keyup.enter="lookupReturn" />
<el-input
v-if="returnContext && !currentReturnIsZhongjian"
v-if="returnContext && !currentReturnIsZhongjian && !returnMaterialMatched && !returnConfirmed"
ref="returnMaterialInputRef"
v-model="returnMaterialQr"
size="large"
placeholder="扫描平台验真吊牌"
placeholder="扫描或填写平台验真吊牌链接"
clearable
@keyup.enter="confirmReturnReport"
@keyup.enter="handleReturnReportStep"
/>
<el-alert
v-if="returnContext && currentReturnIsZhongjian"
v-if="returnContext && currentReturnIsZhongjian && !returnConfirmed"
type="info"
:closable="false"
show-icon
title="中检订单不扫描平台验真吊牌"
description="请核对中检报告编号和报告文件,确认无误后进入回寄物流填写。"
description="请打开报告详情,核对中检报告编号和报告文件,确认无误后填写回寄物流。"
/>
<el-alert
v-if="returnContext && returnMaterialMatched && !returnConfirmed"
type="success"
:closable="false"
show-icon
title="验真吊牌已匹配当前订单报告"
description="请继续核对报告详情,确认无误后填写回寄物流。"
/>
<el-alert
v-if="returnContext && returnConfirmed"
type="success"
:closable="false"
show-icon
title="报告已确认"
description="可填写回寄运单并上传打包装箱图片或视频。"
/>
<div class="actions-row">
<el-button type="primary" :loading="loading" @click="lookupReturn">打开订单</el-button>
<el-button type="success" :loading="actionLoading" :disabled="!returnContext" @click="confirmReturnReport">
{{ currentReturnIsZhongjian ? "报告已确认" : "验真吊牌确认" }}
<el-button
v-if="returnContext && !returnConfirmed"
type="success"
:loading="actionLoading"
:disabled="!returnContext"
@click="handleReturnReportStep"
>
{{ returnReportActionText }}
</el-button>
</div>
<div v-if="returnContext" class="return-form">
<div v-if="returnContext && returnConfirmed" class="return-form">
<el-input v-model="returnExpressCompany" size="large" placeholder="回寄快递公司,例如:顺丰速运" />
<el-input ref="returnTrackingInputRef" v-model="returnTrackingNo" size="large" placeholder="扫描或输入回寄运单号" @keyup.enter="shipReturn" />
<el-button type="primary" size="large" :loading="actionLoading" :disabled="!returnConfirmed" @click="shipReturn">提交寄回</el-button>
<div class="packing-upload">
<div class="packing-upload-head">
<el-upload
:show-file-list="false"
:http-request="uploadReturnPackingAttachment"
:disabled="returnPackingUploading"
accept="image/*,video/*"
multiple
>
<el-button :loading="returnPackingUploading">上传装箱图片/视频</el-button>
</el-upload>
<span class="packing-upload-hint">{{ returnPackingAttachments.length }} 个装箱附件</span>
</div>
<div v-if="returnPackingAttachments.length" class="packing-file-list">
<div v-for="file in returnPackingAttachments" :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="removeReturnPackingAttachment(file.file_url)">移除</el-button>
</div>
</div>
</div>
<el-button
type="primary"
size="large"
:loading="actionLoading"
:disabled="returnPackingUploading || !returnExpressCompany.trim() || !returnTrackingNo.trim()"
@click="shipReturn"
>
提交寄回
</el-button>
</div>
</div>
</el-card>
<OrderContextCard :context="returnContext" @open-file="openFile" />
</div>
<el-drawer v-model="returnReviewDrawerVisible" size="58%" title="回寄前报告核对">
<div v-loading="returnReviewLoading" class="return-review">
<template v-if="returnReviewReport">
<el-alert
type="info"
:closable="false"
show-icon
title="请核对报告编号、结论、附件和验真信息"
description="确认无误后点击确认寄回,系统才会允许填写回寄运单。"
/>
<div class="return-review-grid">
<div class="detail-card">
<div class="detail-card__title">报告概览</div>
<div class="detail-card__desc">
<div class="detail-label">报告编号</div>
<div class="detail-value">{{ returnReviewReport.report_header.report_no }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">报告标题</div>
<div class="detail-value">{{ returnReviewReport.report_header.report_title }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">报告状态</div>
<div class="detail-value">{{ returnReviewReport.report_header.report_status_text }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">发布时间</div>
<div class="detail-value">{{ returnReviewReport.report_header.publish_time || "-" }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">商品与结论</div>
<div class="detail-card__desc">
<div class="detail-label">商品名称</div>
<div class="detail-value">{{ returnReviewReport.product_info.product_name || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">品类 / 品牌</div>
<div class="detail-value">{{ returnReviewReport.product_info.category_name || "-" }} / {{ returnReviewReport.product_info.brand_name || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">鉴定结论</div>
<div class="detail-value">{{ returnReviewReport.result_info.result_text || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">结论说明</div>
<div class="detail-value">{{ returnReviewReport.result_info.result_desc || "-" }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">验真信息</div>
<template v-if="returnReviewReport.report_header.service_provider === 'zhongjian'">
<div class="detail-card__desc">
<div class="detail-label">中检报告编号</div>
<div class="detail-value">{{ returnReviewReport.report_header.zhongjian_report_no || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">报告录入人</div>
<div class="detail-value">{{ returnReviewReport.report_header.report_entry_admin_name || "-" }}</div>
</div>
</template>
<template v-else>
<div class="detail-card__desc">
<div class="detail-label">验真状态</div>
<div class="detail-value">{{ returnReviewReport.verify_info.verify_status || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">验真链接</div>
<div class="detail-value break-text">{{ returnReviewReport.verify_info.verify_url || "-" }}</div>
</div>
</template>
</div>
<div class="detail-card">
<div class="detail-card__title">估值与评级</div>
<div class="detail-card__desc">
<div class="detail-label">成色评级</div>
<div class="detail-value">{{ returnReviewReport.valuation_info.condition_grade || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">估值区间</div>
<div class="detail-value">¥{{ returnReviewReport.valuation_info.valuation_min || 0 }} - ¥{{ returnReviewReport.valuation_info.valuation_max || 0 }}</div>
</div>
</div>
<div class="detail-card return-review-files">
<div class="detail-card__title">报告附件</div>
<div v-if="returnReviewReport.evidence_attachments.length || returnReviewReport.zhongjian_report_files.length" class="file-list">
<button
v-for="file in returnReviewReport.evidence_attachments"
:key="`evidence-${file.file_url}`"
class="file-button"
type="button"
@click="openFile(file.file_url)"
>
{{ file.name || file.file_url }}
</button>
<button
v-for="file in returnReviewReport.zhongjian_report_files"
:key="`zhongjian-${file.file_url}`"
class="file-button"
type="button"
@click="openFile(file.file_url)"
>
{{ file.name || file.file_url }}
</button>
</div>
<div v-else class="detail-card__desc">
<div class="detail-value">暂无报告附件</div>
</div>
</div>
</div>
<div class="return-review-actions">
<el-button @click="returnReviewDrawerVisible = false">取消</el-button>
<el-button type="primary" :loading="returnConfirmLoading" @click="confirmReturnReview">确认寄回</el-button>
</div>
</template>
<el-empty v-else description="暂无报告详情" />
</div>
</el-drawer>
</div>
</template>
@@ -397,6 +714,40 @@ function openFile(url: string) {
border-top: 1px solid var(--admin-border);
}
.packing-upload {
display: grid;
gap: 10px;
padding: 12px;
border: 1px dashed var(--admin-border);
border-radius: 8px;
background: #fffdfa;
}
.packing-upload-head {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.packing-upload-hint,
.packing-file-type {
color: var(--admin-text-subtle);
font-size: 13px;
}
.packing-file-list {
display: grid;
gap: 8px;
}
.packing-file-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
}
.empty-context {
min-height: 260px;
display: grid;
@@ -541,4 +892,42 @@ function openFile(url: string) {
.flow-log-item__remark {
margin-top: 6px;
}
.flow-log-files {
display: grid;
gap: 8px;
margin-top: 10px;
}
.flow-log-files__title {
color: var(--admin-text-main);
font-size: 12px;
font-weight: 700;
}
.return-review {
min-height: 260px;
}
.return-review-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.return-review-files {
grid-column: 1 / -1;
}
.return-review-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 18px;
}
.break-text {
word-break: break-all;
}
</style>

View File

@@ -75,6 +75,7 @@ class AppraisalTasksController
->select()
->toArray();
$this->applyTaskScopeFilterRows($allRows, $request, $scope);
$this->attachTransferFlowToRows($allRows);
$list = $this->buildGroupedTaskList($allRows, $reportMap);
$total = count($list);
@@ -166,6 +167,7 @@ class AppraisalTasksController
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
$materialTag = $report ? (new MaterialTagService())->findBoundTagForReport((int)$report['id']) : null;
$transferFlow = $this->latestTransferFlowForOrder((int)$task['order_id']);
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
if ($effectiveStatus !== $task['status']) {
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update([
@@ -232,6 +234,7 @@ class AppraisalTasksController
->order('t.id', 'asc')
->select()
->toArray();
$this->attachTransferFlowToRows($stageTaskRows);
$stageTasks = array_map(function (array $item) use ($id, $stageReportMap) {
$row = $this->normalizeTaskListRow($item, $stageReportMap[(int)$item['order_id']] ?? null);
@@ -305,6 +308,7 @@ class AppraisalTasksController
'submitted_at' => $task['submitted_at'],
'sla_deadline' => $task['sla_deadline'],
'is_overtime' => (bool)$task['is_overtime'],
'internal_tag_no' => (string)($transferFlow['internal_tag_no'] ?? ''),
],
'report_summary' => $report ? [
'id' => (int)$report['id'],
@@ -373,10 +377,6 @@ class AppraisalTasksController
if (!$task) {
return api_error('任务不存在', 404);
}
if (($task['service_provider'] ?? '') === 'zhongjian') {
return api_error('中检订单不使用平台验真吊牌', 422);
}
$operatorGuard = $this->guardTaskOperator($request, $task);
if ($operatorGuard['error']) {
return $operatorGuard['error'];
@@ -426,23 +426,25 @@ class AppraisalTasksController
if (!$task) {
return api_error('任务不存在', 404);
}
if (($task['service_provider'] ?? '') === 'zhongjian') {
return api_error('中检订单不使用平台验真吊牌', 422);
}
Db::startTrans();
try {
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
if (!$report) {
Db::rollback();
return api_error('请先提交鉴定结论生成报告草稿', 422);
}
$publish = $this->publishReportRecord($report, $request);
$publish = $this->publishReportRecord($report, $request, false);
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
Db::commit();
} catch (\InvalidArgumentException $e) {
Db::rollback();
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
Db::rollback();
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
Db::rollback();
return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]);
}
@@ -457,6 +459,7 @@ class AppraisalTasksController
{
$id = (int)$request->input('id', 0);
$reportNo = trim((string)$request->input('zhongjian_report_no', ''));
$qrInput = trim((string)$request->input('qr_input', ''));
$files = $this->evidenceService()->normalize($request->input('report_files', []), $request, true);
if ($id <= 0) {
return api_error('任务 ID 不能为空', 422);
@@ -467,6 +470,9 @@ class AppraisalTasksController
if (!$files) {
return api_error('请至少上传 1 个中检报告文件', 422);
}
if ($qrInput === '') {
return api_error('请扫描验真吊牌二维码', 422);
}
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
if (!$task) {
@@ -475,6 +481,20 @@ class AppraisalTasksController
if (($task['service_provider'] ?? '') !== 'zhongjian') {
return api_error('非中检订单不能录入中检报告', 422);
}
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
$task['order_status'] = $order['order_status'] ?? '';
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
if ($effectiveStatus !== $task['status']) {
Db::name('appraisal_tasks')->where('id', $id)->update([
'status' => $effectiveStatus,
'updated_at' => date('Y-m-d H:i:s'),
]);
$task['status'] = $effectiveStatus;
}
if (in_array($effectiveStatus, ['submitted', 'completed'], true)) {
return api_error('当前任务已流转完成,不能再录入中检报告', 422);
}
$operatorGuard = $this->guardTaskOperator($request, $task);
if ($operatorGuard['error']) {
@@ -565,22 +585,25 @@ class AppraisalTasksController
'created_at' => $now,
]);
Db::commit();
$freshReport = $this->findLatestAppraisalReport((int)$task['order_id']);
$publish = $this->publishReportRecord($freshReport, $request);
if (!$freshReport) {
Db::rollback();
return api_error('中检报告草稿生成失败', 500);
}
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
$publish = $this->publishReportRecord($freshReport, $request, false);
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
Db::commit();
return api_success([
'id' => $id,
'material_tag' => $tag,
'report' => $publish,
], '中检报告已录入并发布');
], '验真吊牌已绑定,报告已发布');
} catch (\Throwable $e) {
try {
Db::rollback();
} catch (\Throwable $rollbackError) {
// Transaction may already be committed before publishing.
}
Db::rollback();
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
}
}
@@ -640,6 +663,7 @@ class AppraisalTasksController
{
$id = (int)$request->input('id', 0);
$action = trim((string)$request->input('action', 'save'));
$qrInput = trim((string)$request->input('qr_input', ''));
if (!$id) {
return api_error('任务 ID 不能为空', 422);
@@ -675,6 +699,9 @@ class AppraisalTasksController
if ($action !== 'save' && $resultText === '') {
return api_error('鉴定结论不能为空', 422);
}
if ($action !== 'save' && $qrInput === '') {
return api_error('请扫描验真吊牌二维码', 422);
}
$productInput = $request->input('product_info', null);
$productPayload = is_array($productInput) ? $this->normalizeProductInput($productInput) : null;
$attachments = $this->evidenceService()->normalize($request->input('attachments', []), $request, true);
@@ -774,6 +801,14 @@ class AppraisalTasksController
]);
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now);
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
if (!$report) {
Db::rollback();
return api_error('报告草稿生成失败', 500);
}
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
$publish = $this->publishReportRecord($report, $request, false);
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
Db::commit();
(new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [
@@ -781,7 +816,11 @@ class AppraisalTasksController
'task_stage' => $task['task_stage'],
'finished_at' => $now,
]);
return api_success(['id' => $id], '鉴定已完成,报告草稿已生成');
return api_success([
'id' => $id,
'material_tag' => $tag,
'report' => $publish,
], '验真吊牌已绑定,报告已发布');
} catch (\Throwable $e) {
Db::rollback();
return api_error('结论保存失败', 500, [
@@ -976,6 +1015,15 @@ class AppraisalTasksController
public function uploadEvidenceFile(Request $request)
{
$taskId = (int)$request->input('task_id', 0);
if ($taskId <= 0) {
return api_error('任务 ID 不能为空', 422);
}
$editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再上传附件');
if ($editableGuard) {
return $editableGuard;
}
try {
$asset = $this->evidenceService()->upload($request);
return api_success($asset);
@@ -986,6 +1034,15 @@ class AppraisalTasksController
public function deleteEvidenceFile(Request $request)
{
$taskId = (int)$request->input('task_id', 0);
if ($taskId <= 0) {
return api_error('任务 ID 不能为空', 422);
}
$editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再删除附件');
if ($editableGuard) {
return $editableGuard;
}
$fileUrl = trim((string)$request->input('file_url', ''));
if ($fileUrl === '') {
return api_error('文件地址不能为空', 422);
@@ -1093,9 +1150,86 @@ class AppraisalTasksController
'sla_deadline' => $item['sla_deadline'],
'is_overtime' => (bool)$item['is_overtime'],
'display_status' => $item['display_status'],
'internal_tag_no' => (string)($item['internal_tag_no'] ?? ''),
];
}
private function attachTransferFlowToRows(array &$rows): void
{
$orderIds = array_values(array_unique(array_filter(array_map(fn (array $item) => (int)($item['order_id'] ?? 0), $rows))));
if (!$orderIds) {
return;
}
$flowMap = $this->latestTransferFlowMap($orderIds);
foreach ($rows as &$row) {
$orderId = (int)($row['order_id'] ?? 0);
$row['internal_tag_no'] = (string)($flowMap[$orderId]['internal_tag_no'] ?? '');
}
unset($row);
}
private function latestTransferFlowForOrder(int $orderId): ?array
{
if ($orderId <= 0) {
return null;
}
return Db::name('order_transfer_flows')
->where('order_id', $orderId)
->order('id', 'desc')
->find() ?: null;
}
private function latestTransferFlowMap(array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if (!$orderIds) {
return [];
}
$rows = Db::name('order_transfer_flows')
->whereIn('order_id', $orderIds)
->order('id', 'desc')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$orderId = (int)($row['order_id'] ?? 0);
if ($orderId > 0 && !isset($map[$orderId])) {
$map[$orderId] = [
'internal_tag_no' => (string)($row['internal_tag_no'] ?? ''),
];
}
}
return $map;
}
private function guardTaskEditable(int $taskId, string $message)
{
$task = Db::name('appraisal_tasks')->where('id', $taskId)->find();
if (!$task) {
return api_error('任务不存在', 404);
}
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
$task['order_status'] = $order['order_status'] ?? '';
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
if ($effectiveStatus !== (string)$task['status']) {
Db::name('appraisal_tasks')->where('id', $taskId)->update([
'status' => $effectiveStatus,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return in_array($effectiveStatus, ['submitted', 'completed'], true)
? api_error($message, 422)
: null;
}
private function formatResultInfo(array $task, ?Request $request = null): array
{
$resultId = 0;
@@ -1866,7 +2000,7 @@ class AppraisalTasksController
return $admin;
}
private function publishReportRecord(array $report, Request $request): array
private function publishReportRecord(array $report, Request $request, bool $wrapTransaction = true): array
{
if (!$report) {
throw new \RuntimeException('报告不存在', 404);
@@ -1878,10 +2012,11 @@ class AppraisalTasksController
$operatorId = (int)$request->header('x-admin-id', 0);
$now = date('Y-m-d H:i:s');
$effectivePublishTime = $report['publish_time'] ?: $now;
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
$verify = [];
Db::startTrans();
if ($wrapTransaction) {
Db::startTrans();
}
try {
if (($report['report_status'] ?? '') !== 'published') {
Db::name('reports')->where('id', (int)$report['id'])->update([
@@ -1893,9 +2028,7 @@ class AppraisalTasksController
$report['publish_time'] = $effectivePublishTime;
}
if ($usesPlatformVerify) {
$verify = $this->createOrUpdateVerifyRecord($report, $now);
}
$verify = $this->createOrUpdateVerifyRecord($report, $now);
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
Db::name('orders')->where('id', (int)$report['order_id'])->update([
@@ -1933,15 +2066,19 @@ class AppraisalTasksController
'report_title' => (string)$report['report_title'],
'product_name' => $product['product_name'] ?? '',
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'verify_url' => (string)($verify['verify_url'] ?? ''),
'fallback_title' => '报告已出具',
'fallback_content' => '您的正式报告已生成,可前往报告中心查看。',
]);
}
Db::commit();
if ($wrapTransaction) {
Db::commit();
}
} catch (\Throwable $e) {
Db::rollback();
if ($wrapTransaction) {
Db::rollback();
}
throw $e;
}
@@ -1951,8 +2088,8 @@ class AppraisalTasksController
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
'verify_url' => (string)($verify['verify_url'] ?? ''),
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
]);
}
@@ -1960,8 +2097,8 @@ class AppraisalTasksController
'id' => (int)$report['id'],
'report_status' => 'published',
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
'verify_url' => (string)($verify['verify_url'] ?? ''),
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
];
}

View File

@@ -2,6 +2,7 @@
namespace app\controller\admin;
use app\support\AppraisalEvidenceService;
use app\support\MessageDispatcher;
use app\support\EnterpriseWebhookService;
use app\support\WarehouseService;
@@ -10,6 +11,8 @@ use support\think\Db;
class OrdersController
{
private const MANUAL_ENTRY_SOURCE = 'manual_entry';
public function index(Request $request)
{
$keyword = trim((string)$request->input('keyword', ''));
@@ -56,6 +59,7 @@ class OrdersController
$warehouseStatusFilters = [
'warehouse_active',
'warehouse_pending_inbound',
'warehouse_in_transit',
'warehouse_received',
'warehouse_pending_return',
@@ -77,6 +81,9 @@ class OrdersController
];
if ($status === 'warehouse_in_transit') {
$query->where('o.order_status', 'pending_shipping');
} elseif ($status === 'warehouse_pending_inbound') {
$query->where('o.order_status', 'pending_shipping')
->where('o.source_channel', self::MANUAL_ENTRY_SOURCE);
} elseif ($status === 'warehouse_received') {
$query->whereIn('o.order_status', array_values(array_diff($warehouseActiveStatuses, ['pending_shipping', 'report_published'])));
} elseif ($status === 'warehouse_pending_return') {
@@ -99,8 +106,9 @@ class OrdersController
$orderIds = array_map('intval', array_column($rows, 'id'));
$sendTrackingMap = $this->latestLogisticsMap($orderIds, 'send_to_center');
$returnTrackingMap = $this->latestLogisticsMap($orderIds, 'return_to_user');
$transferFlowMap = $this->latestTransferFlowMap($orderIds);
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap) {
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap, $transferFlowMap) {
$orderId = (int)$item['id'];
$sendTrackingNo = $sendTrackingMap[$orderId]['tracking_no'] ?? '';
$sendTrackingStatus = $sendTrackingMap[$orderId]['tracking_status'] ?? '';
@@ -108,7 +116,8 @@ class OrdersController
(string)$item['order_status'],
$sendTrackingNo,
$sendTrackingStatus,
(string)($item['display_status'] ?? '')
(string)($item['display_status'] ?? ''),
(string)($item['source_channel'] ?? '')
);
return [
@@ -130,6 +139,7 @@ class OrdersController
$returnTrackingMap[$orderId]['tracking_no'] ?? '',
$returnTrackingMap[$orderId]['tracking_status'] ?? '',
),
'internal_tag_no' => $transferFlowMap[$orderId]['internal_tag_no'] ?? '',
'warehouse_bucket' => $warehouseBucket,
'warehouse_bucket_text' => $this->warehouseOrderBucketText($warehouseBucket),
'estimated_finish_time' => $item['estimated_finish_time'],
@@ -154,6 +164,7 @@ class OrdersController
$list = array_values(array_filter($list, function (array $item) use ($status) {
if ($status === 'warehouse_active') {
return in_array($item['warehouse_bucket'], [
'warehouse_pending_inbound',
'warehouse_in_transit',
'warehouse_received',
'warehouse_pending_return',
@@ -206,6 +217,10 @@ class OrdersController
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
$transferFlow = Db::name('order_transfer_flows')
->where('order_id', $id)
->order('id', 'desc')
->find();
$timeline = Db::name('order_timelines')
->where('order_id', $id)
->order('occurred_at', 'asc')
@@ -268,6 +283,7 @@ class OrdersController
->select()
->toArray();
}
$inboundAttachments = $this->inboundAttachments($id, $request);
$returnLogisticsNodes = [];
if ($returnLogistics) {
$returnLogisticsNodes = Db::name('order_logistics_nodes')
@@ -352,6 +368,9 @@ class OrdersController
)),
] : null,
'timeline' => $timeline,
'transfer_flow' => $transferFlow ? [
'internal_tag_no' => (string)($transferFlow['internal_tag_no'] ?? ''),
] : null,
'logistics_info' => $sendLogistics ? [
'express_company' => $sendLogistics['express_company'],
'tracking_no' => $sendLogistics['tracking_no'],
@@ -377,6 +396,7 @@ class OrdersController
'node_location' => $item['node_location'],
], $logisticsNodes),
] : null,
'inbound_attachments' => $inboundAttachments,
'return_logistics' => $returnLogistics ? [
'express_company' => $returnLogistics['express_company'],
'tracking_no' => $returnLogistics['tracking_no'],
@@ -907,6 +927,440 @@ class OrdersController
return api_success(['id' => $id], '已标记用户签收');
}
public function createManualOrder(Request $request)
{
$serviceProvider = $this->normalizeServiceProvider((string)$request->input('service_provider', 'anxinyan'));
$productInput = $this->requestArray($request, 'product_info');
$extraInput = $this->requestArray($request, 'extra_info');
$returnAddressInput = $this->requestArray($request, 'return_address');
$materialsInput = $request->input('materials', []);
$materials = is_array($materialsInput) ? $materialsInput : [];
$categoryId = (int)($productInput['category_id'] ?? 0);
$brandId = (int)($productInput['brand_id'] ?? 0);
$productName = trim((string)($productInput['product_name'] ?? ''));
$consignee = trim((string)($returnAddressInput['consignee'] ?? ''));
$mobile = trim((string)($returnAddressInput['mobile'] ?? ''));
$province = trim((string)($returnAddressInput['province'] ?? ''));
$city = trim((string)($returnAddressInput['city'] ?? ''));
$district = trim((string)($returnAddressInput['district'] ?? ''));
$detailAddress = trim((string)($returnAddressInput['detail_address'] ?? ''));
if ($serviceProvider === '') {
return api_error('服务类型不正确', 422);
}
if ($categoryId <= 0 || $brandId <= 0 || $productName === '') {
return api_error('请完整填写品类、品牌和商品名称', 422);
}
if ($consignee === '' || $mobile === '' || $province === '' || $city === '' || $district === '' || $detailAddress === '') {
return api_error('请完整填写寄回收件信息', 422);
}
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
if (!$category) {
return api_error('品类不存在', 422);
}
$brand = Db::name('catalog_brands')->where('id', $brandId)->find();
if (!$brand) {
return api_error('品牌不存在', 422);
}
$now = date('Y-m-d H:i:s');
$serviceConfig = $this->serviceConfig($serviceProvider);
$orderNo = $this->generateOrderNo();
$appraisalNo = $this->generateAppraisalNo();
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
$operatorId = (int)$request->header('x-admin-id', 0) ?: null;
Db::startTrans();
try {
$user = $this->resolveManualOrderUser($consignee, $mobile, $now);
$addressId = $this->ensureUserAddress((int)$user['id'], [
'consignee' => $consignee,
'mobile' => $mobile,
'province' => $province,
'city' => $city,
'district' => $district,
'detail_address' => $detailAddress,
], $now);
$orderId = (int)Db::name('orders')->insertGetId([
'order_no' => $orderNo,
'appraisal_no' => $appraisalNo,
'user_id' => (int)$user['id'],
'service_mode' => 'physical',
'service_provider' => $serviceProvider,
'payment_status' => 'paid',
'order_status' => 'pending_shipping',
'display_status' => '待入库',
'estimated_finish_time' => $estimated,
'source_channel' => self::MANUAL_ENTRY_SOURCE,
'source_customer_id' => '',
'pay_amount' => (float)$serviceConfig['price'],
'paid_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_products')->insert([
'order_id' => $orderId,
'category_id' => $categoryId,
'category_name' => (string)$category['name'],
'brand_id' => $brandId,
'brand_name' => (string)$brand['name'],
'color' => trim((string)($productInput['color'] ?? '')),
'size_spec' => trim((string)($productInput['size_spec'] ?? '')),
'serial_no' => trim((string)($productInput['serial_no'] ?? '')),
'product_name' => $productName,
'product_cover' => '',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_extras')->insert([
'order_id' => $orderId,
'purchase_channel' => trim((string)($extraInput['purchase_channel'] ?? '')),
'purchase_price' => (float)($extraInput['purchase_price'] ?? 0),
'purchase_date' => null,
'usage_status' => trim((string)($extraInput['usage_status'] ?? '')),
'condition_desc' => trim((string)($extraInput['condition_desc'] ?? '')),
'has_accessories' => 0,
'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE),
'remark' => trim((string)($extraInput['remark'] ?? '')),
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_return_addresses')->insert([
'order_id' => $orderId,
'user_address_id' => $addressId,
'consignee' => $consignee,
'mobile' => $mobile,
'province' => $province,
'city' => $city,
'district' => $district,
'detail_address' => $detailAddress,
'created_at' => $now,
'updated_at' => $now,
]);
$shippingTarget = (new WarehouseService())->bindOrderTarget($orderId, $serviceProvider, $categoryId, [
'province' => $province,
'city' => $city,
'district' => $district,
'detail_address' => $detailAddress,
]);
$this->insertManualOrderMaterials($orderId, $materials, $now);
Db::name('appraisal_tasks')->insert([
'order_id' => $orderId,
'task_stage' => 'first_review',
'service_provider' => $serviceProvider,
'status' => 'pending',
'assignee_id' => null,
'assignee_name' => '未分配',
'started_at' => null,
'submitted_at' => null,
'sla_deadline' => $estimated,
'is_overtime' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_timelines')->insertAll([
[
'order_id' => $orderId,
'node_code' => 'manual_created',
'node_text' => '补录订单已创建',
'node_desc' => '后台已补录订单资料,等待仓管入库。',
'operator_type' => 'admin',
'operator_id' => $operatorId,
'occurred_at' => $now,
'created_at' => $now,
],
[
'order_id' => $orderId,
'node_code' => 'pending_inbound',
'node_text' => '待入库',
'node_desc' => sprintf('可使用订单号或鉴定单号匹配入库,目标仓库:%s。', $shippingTarget['warehouse_name'] ?: '鉴定中心'),
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
],
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('补录订单创建失败', 500, ['detail' => $e->getMessage()]);
}
return api_success([
'order_id' => $orderId,
'order_no' => $orderNo,
'appraisal_no' => $appraisalNo,
'user_id' => (int)$user['id'],
'next_status' => 'pending_shipping',
], '补录订单已创建');
}
public function manualOrderMeta(Request $request)
{
$categories = Db::name('catalog_categories')
->field(['id', 'name', 'code', 'is_enabled', 'supported_service_types'])
->where('is_enabled', 1)
->order('sort_order', 'asc')
->select()
->toArray();
$brands = Db::name('catalog_brands')
->alias('b')
->leftJoin('catalog_brand_categories cbc', 'cbc.brand_id = b.id')
->field([
'b.id',
'b.name',
'b.en_name',
'b.code',
'b.is_enabled',
'b.supported_service_types',
'GROUP_CONCAT(DISTINCT cbc.category_id) AS category_ids',
])
->where('b.is_enabled', 1)
->group('b.id')
->order('b.sort_order', 'asc')
->select()
->toArray();
return api_success([
'categories' => array_map(fn (array $item) => [
'id' => (int)$item['id'],
'name' => (string)$item['name'],
'code' => (string)$item['code'],
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
], $categories),
'brands' => array_map(fn (array $item) => [
'id' => (int)$item['id'],
'name' => (string)$item['name'],
'en_name' => (string)($item['en_name'] ?? ''),
'code' => (string)($item['code'] ?? ''),
'category_ids' => $this->decodeIntList($item['category_ids'] ?? ''),
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
], $brands),
]);
}
public function uploadManualOrderFile(Request $request)
{
try {
return api_success((new AppraisalEvidenceService())->upload($request));
} catch (\Throwable $e) {
return api_error($e->getMessage(), 422);
}
}
private function inboundAttachments(int $orderId, Request $request): array
{
$logs = Db::name('order_transfer_flow_logs')
->where('order_id', $orderId)
->where('action_code', 'inbound_received')
->order('id', 'desc')
->select()
->toArray();
$attachments = [];
foreach ($logs as $log) {
$payload = $this->decodeJsonObject($log['payload_json'] ?? null);
foreach ($this->decodeJsonArray($payload['inbound_attachments'] ?? []) as $item) {
if (is_array($item)) {
$attachments[] = $item;
}
}
}
$normalized = (new AppraisalEvidenceService())->normalize($attachments, $request);
return array_values(array_filter($normalized, function (array $item) {
return in_array((string)($item['file_type'] ?? ''), ['image', 'video'], true);
}));
}
private function decodeJsonArray(mixed $value): array
{
if (is_array($value)) {
return array_values($value);
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? array_values($decoded) : [];
}
return [];
}
private function decodeJsonObject(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function decodeIntList(mixed $value): array
{
if (is_array($value)) {
return array_values(array_filter(array_map('intval', $value), fn (int $item) => $item > 0));
}
if (!is_string($value) || trim($value) === '') {
return [];
}
return array_values(array_filter(array_map('intval', explode(',', $value)), fn (int $item) => $item > 0));
}
private function requestArray(Request $request, string $key): array
{
$value = $request->input($key, []);
return is_array($value) ? $value : [];
}
private function normalizeServiceProvider(string $serviceProvider): string
{
$serviceProvider = trim($serviceProvider);
return in_array($serviceProvider, ['anxinyan', 'zhongjian'], true) ? $serviceProvider : '';
}
private function serviceConfig(string $serviceProvider): array
{
$configs = [
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
];
return $configs[$serviceProvider] ?? $configs['anxinyan'];
}
private function generateOrderNo(): string
{
do {
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
} while (Db::name('orders')->where('order_no', $orderNo)->find());
return $orderNo;
}
private function generateAppraisalNo(): string
{
do {
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
} while (Db::name('orders')->where('appraisal_no', $appraisalNo)->find());
return $appraisalNo;
}
private function resolveManualOrderUser(string $consignee, string $mobile, string $now): array
{
$user = Db::name('users')
->where('mobile', $mobile)
->whereNull('deleted_at')
->find();
if ($user) {
return $user;
}
$userId = (int)Db::name('users')->insertGetId([
'nickname' => $consignee,
'avatar' => '',
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
return Db::name('users')->where('id', $userId)->find();
}
private function ensureUserAddress(int $userId, array $address, string $now): int
{
$existing = Db::name('user_addresses')
->where('user_id', $userId)
->where('consignee', (string)$address['consignee'])
->where('mobile', (string)$address['mobile'])
->where('province', (string)$address['province'])
->where('city', (string)$address['city'])
->where('district', (string)$address['district'])
->where('detail_address', (string)$address['detail_address'])
->find();
if ($existing) {
return (int)$existing['id'];
}
$hasDefault = Db::name('user_addresses')
->where('user_id', $userId)
->where('is_default', 1)
->find();
return (int)Db::name('user_addresses')->insertGetId([
'user_id' => $userId,
'consignee' => (string)$address['consignee'],
'mobile' => (string)$address['mobile'],
'province' => (string)$address['province'],
'city' => (string)$address['city'],
'district' => (string)$address['district'],
'detail_address' => (string)$address['detail_address'],
'is_default' => $hasDefault ? 0 : 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function insertManualOrderMaterials(int $orderId, array $materials, string $now): void
{
$evidenceService = new AppraisalEvidenceService();
foreach ($materials as $index => $item) {
if (!is_array($item)) {
continue;
}
$files = $evidenceService->normalize($item['files'] ?? [], null, true);
if (!$files) {
continue;
}
$orderUploadId = (int)Db::name('order_upload_items')->insertGetId([
'order_id' => $orderId,
'template_id' => null,
'item_code' => trim((string)($item['item_code'] ?? 'manual_material_' . ($index + 1))),
'item_name' => trim((string)($item['item_name'] ?? '补录资料')),
'is_required' => !empty($item['is_required']) ? 1 : 0,
'source_type' => 'initial',
'status' => 'uploaded',
'created_at' => $now,
'updated_at' => $now,
]);
foreach ($files as $file) {
Db::name('order_upload_files')->insert([
'order_upload_item_id' => $orderUploadId,
'file_id' => (string)($file['file_id'] ?? ''),
'file_url' => ltrim((string)($file['file_url'] ?? ''), '/'),
'thumbnail_url' => ltrim((string)($file['thumbnail_url'] ?? ''), '/'),
'quality_status' => 'uploaded',
'quality_message' => '',
'uploaded_by_user_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
{
if ($logisticsType === 'return_to_user') {
@@ -984,14 +1438,45 @@ class OrdersController
return $map;
}
private function latestTransferFlowMap(array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if (!$orderIds) {
return [];
}
$rows = Db::name('order_transfer_flows')
->whereIn('order_id', $orderIds)
->order('id', 'desc')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$orderId = (int)($row['order_id'] ?? 0);
if ($orderId > 0 && !isset($map[$orderId])) {
$map[$orderId] = [
'internal_tag_no' => (string)($row['internal_tag_no'] ?? ''),
];
}
}
return $map;
}
private function warehouseOrderBucket(
string $orderStatus,
string $sendTrackingNo = '',
string $sendTrackingStatus = '',
string $displayStatus = ''
string $displayStatus = '',
string $sourceChannel = ''
): string
{
if ($orderStatus === 'pending_shipping') {
if ($sourceChannel === self::MANUAL_ENTRY_SOURCE && $sendTrackingNo === '') {
return 'warehouse_pending_inbound';
}
$hasSubmittedTracking = $sendTrackingNo !== '' && $sendTrackingStatus !== 'received';
$hasSubmittedDisplayStatus = in_array($displayStatus, ['已提交运单', '用户已提交运单'], true)
&& $sendTrackingStatus !== 'received';
@@ -1020,6 +1505,7 @@ class OrdersController
private function warehouseOrderBucketText(string $bucket): string
{
return match ($bucket) {
'warehouse_pending_inbound' => '待入库',
'warehouse_in_transit' => '在途',
'warehouse_received' => '已入仓',
'warehouse_pending_return' => '待寄回',
@@ -1041,10 +1527,13 @@ class OrdersController
'enterprise_order' => 'enterprise_push',
'customer_push' => 'enterprise_push',
'large_customer_push' => 'enterprise_push',
'manual' => self::MANUAL_ENTRY_SOURCE,
'manual_order' => self::MANUAL_ENTRY_SOURCE,
'manual_entry' => self::MANUAL_ENTRY_SOURCE,
];
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push', self::MANUAL_ENTRY_SOURCE], true) ? $sourceChannel : '';
}
private function sourceChannelText(string $sourceChannel): string
@@ -1053,6 +1542,7 @@ class OrdersController
'mini_program' => '小程序',
'h5' => 'H5',
'enterprise_push' => '大客户推送订单',
self::MANUAL_ENTRY_SOURCE => '后台补录订单',
default => '未知渠道',
};
}

View File

@@ -5,6 +5,8 @@ namespace app\controller\admin;
use app\support\AppraisalEvidenceService;
use app\support\ContentService;
use app\support\EnterpriseWebhookService;
use app\support\FulfillmentFlowService;
use app\support\MaterialTagService;
use app\support\MessageDispatcher;
use support\Request;
use support\think\Db;
@@ -24,6 +26,7 @@ class ReportsController
->alias('r')
->leftJoin('orders o', 'o.id = r.order_id')
->leftJoin('order_products p', 'p.order_id = r.order_id')
->leftJoin('material_tag_codes mt', 'mt.report_id = r.id')
->field([
'r.id',
'r.report_no',
@@ -42,6 +45,9 @@ class ReportsController
'p.product_name',
'p.category_name',
'p.brand_name',
'mt.id as material_tag_id',
'mt.verify_code as material_tag_verify_code',
'mt.bind_status as material_tag_bind_status',
])
->order('r.id', 'desc');
@@ -80,6 +86,9 @@ class ReportsController
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
'material_tag_bound' => (int)($item['material_tag_id'] ?? 0) > 0,
'material_tag_verify_code' => (string)($item['material_tag_verify_code'] ?? ''),
'material_tag_bind_status' => (string)($item['material_tag_bind_status'] ?? ''),
];
if ($keyword !== '' && !$this->matchKeyword($mapped, $keyword)) {
@@ -125,21 +134,21 @@ class ReportsController
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
$verify = $usesPlatformVerify ? (Db::name('report_verifies')->where('report_id', $id)->find() ?: []) : [];
if ($usesPlatformVerify && ($report['report_status'] ?? '') === 'published') {
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
if (($report['report_status'] ?? '') === 'published') {
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
}
$reportPageUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]) : '';
$verifyUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]) : '';
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]);
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]);
if (!$verify) {
$verify = [];
}
$verify['report_page_url'] = $usesPlatformVerify ? ($verify['report_page_url'] ?? $reportPageUrl) : '';
$verify['verify_qrcode_url'] = $usesPlatformVerify ? ($verify['verify_qrcode_url'] ?? $reportPageUrl) : '';
$verify['verify_url'] = $usesPlatformVerify ? ($verify['verify_url'] ?? $verifyUrl) : '';
$verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl;
$verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl;
$verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl;
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
return api_success([
@@ -167,6 +176,7 @@ class ReportsController
'valuation_info' => $valuationSnapshot,
'evidence_attachments' => $evidenceAttachments,
'zhongjian_report_files' => $zhongjianReportFiles,
'material_tag' => $materialTag,
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
'verify_info' => [
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
@@ -333,11 +343,9 @@ class ReportsController
'verify_url' => '',
'report_page_url' => '',
];
$usesPlatformVerify = $serviceProvider !== 'zhongjian';
if ($reportStatus === 'published' && $reportRecord && $usesPlatformVerify) {
if ($reportStatus === 'published' && $reportRecord) {
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
} else {
} elseif ($reportStatus !== 'published') {
Db::name('report_verifies')->where('report_id', $reportId)->delete();
}
@@ -361,6 +369,7 @@ class ReportsController
public function publish(Request $request)
{
$id = (int)$request->input('id', 0);
$qrInput = trim((string)$request->input('qr_input', ''));
if (!$id) {
return api_error('报告 ID 不能为空', 422);
}
@@ -381,7 +390,29 @@ class ReportsController
}
$effectivePublishTime = $report['publish_time'] ?: $now;
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
$isOrderAppraisalReport = ($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0;
$materialTag = null;
if ($isOrderAppraisalReport) {
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
if (!$materialTag) {
if ($qrInput === '') {
Db::rollback();
return api_error('请扫描验真吊牌二维码后再发布报告', 422);
}
$task = Db::name('appraisal_tasks')
->where('order_id', (int)$report['order_id'])
->order('id', 'desc')
->find();
if (!$task) {
Db::rollback();
return api_error('报告未关联鉴定任务,不能绑定吊牌发布', 422);
}
$materialTag = (new MaterialTagService())->bindTagToReportByTask((int)$task['id'], $qrInput, $request);
}
}
if ($report['report_status'] !== 'published') {
Db::name('reports')->where('id', $id)->update([
'report_status' => 'published',
@@ -392,18 +423,13 @@ class ReportsController
$report['publish_time'] = $effectivePublishTime;
}
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
if ($isOrderAppraisalReport) {
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
}
$verify = [];
if ($usesPlatformVerify) {
$verify = $this->createOrUpdateVerifyRecord($report, $now);
} else {
Db::name('report_verifies')->where('report_id', $id)->delete();
}
$verify = $this->createOrUpdateVerifyRecord($report, $now);
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
if ($isOrderAppraisalReport) {
Db::name('orders')->where('id', $report['order_id'])->update([
'order_status' => 'report_published',
'display_status' => '报告已出具',
@@ -424,7 +450,7 @@ class ReportsController
'order_id' => $report['order_id'],
'node_code' => 'report_published',
'node_text' => '报告已出具',
'node_desc' => $usesPlatformVerify ? '正式报告已发布,用户可查看报告并进行验真。' : '中检报告已发布,用户可查看报告。',
'node_desc' => '正式报告已发布,用户可查看报告并进行验真。',
'operator_type' => 'admin',
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'occurred_at' => $now,
@@ -440,22 +466,24 @@ class ReportsController
'report_title' => $report['report_title'],
'product_name' => $product['product_name'] ?? '',
'publish_time' => $report['publish_time'] ?: $now,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'verify_url' => (string)($verify['verify_url'] ?? ''),
'fallback_title' => '报告已出具',
'fallback_content' => $usesPlatformVerify ? '您的正式报告已生成,可前往报告中心查看并完成验真。' : '您的中检报告已生成,可前往报告中心查看。',
'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。',
]);
(new FulfillmentFlowService())->markReportPublished((int)$report['order_id'], $request);
}
Db::commit();
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
if ($isOrderAppraisalReport) {
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
'report_id' => $id,
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
'verify_url' => (string)($verify['verify_url'] ?? ''),
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
]);
}
@@ -463,8 +491,9 @@ class ReportsController
'id' => $id,
'report_status' => 'published',
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
'verify_url' => (string)($verify['verify_url'] ?? ''),
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
'material_tag' => $materialTag,
], '报告已发布');
} catch (\Throwable $e) {
Db::rollback();

View File

@@ -2,6 +2,7 @@
namespace app\controller\admin;
use app\support\AppraisalEvidenceService;
use app\support\FulfillmentFlowService;
use support\Request;
@@ -10,7 +11,7 @@ class WarehouseWorkbenchController
public function inboundLookup(Request $request)
{
try {
return api_success($this->service()->lookupInboundByTrackingNo((string)$request->input('tracking_no', '')));
return api_success($this->service()->lookupInboundByInboundNo($this->inboundNo($request)));
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
@@ -24,9 +25,10 @@ class WarehouseWorkbenchController
{
try {
return api_success($this->service()->receiveInbound(
(string)$request->input('tracking_no', ''),
$this->inboundNo($request),
(string)$request->input('internal_tag_no', ''),
$request
$request,
$request->input('inbound_attachments', [])
), '入库完成');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
@@ -37,6 +39,38 @@ class WarehouseWorkbenchController
}
}
public function uploadInboundEvidenceFile(Request $request)
{
$evidenceService = new AppraisalEvidenceService();
try {
$asset = $evidenceService->upload($request);
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
$evidenceService->delete((string)($asset['file_url'] ?? ''));
return api_error('拆包附件仅支持上传图片或视频', 422);
}
return api_success($asset);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 422);
}
}
public function uploadReturnPackingFile(Request $request)
{
$evidenceService = new AppraisalEvidenceService();
try {
$asset = $evidenceService->upload($request);
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
$evidenceService->delete((string)($asset['file_url'] ?? ''));
return api_error('打包装箱附件仅支持上传图片或视频', 422);
}
return api_success($asset);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 422);
}
}
public function zhongjianLookup(Request $request)
{
try {
@@ -96,7 +130,7 @@ class WarehouseWorkbenchController
(string)$request->input('internal_tag_no', ''),
(string)$request->input('qr_input', ''),
$request
), '验真吊牌已确认');
), '验真吊牌匹配通过,请核对报告');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
@@ -119,6 +153,23 @@ class WarehouseWorkbenchController
}
}
public function confirmReturnReport(Request $request)
{
try {
return api_success($this->service()->confirmReturnReport(
(string)$request->input('internal_tag_no', ''),
(int)$request->input('report_id', 0),
$request
), '报告已确认,可填写回寄运单');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('报告确认失败', 500, ['detail' => $e->getMessage()]);
}
}
public function shipReturn(Request $request)
{
try {
@@ -126,7 +177,8 @@ class WarehouseWorkbenchController
(string)$request->input('internal_tag_no', ''),
(string)$request->input('express_company', ''),
(string)$request->input('tracking_no', ''),
$request
$request,
$request->input('packing_attachments', [])
), '回寄运单已登记');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
@@ -141,4 +193,26 @@ class WarehouseWorkbenchController
{
return new FulfillmentFlowService();
}
private function inboundNo(Request $request): string
{
$inboundNo = $this->requestString($request, 'inbound_no');
if ($inboundNo !== '') {
return $inboundNo;
}
return $this->requestString($request, 'tracking_no');
}
private function requestString(Request $request, string $key): string
{
foreach ([$request->get($key, null), $request->post($key, null), $request->input($key, null)] as $value) {
$text = trim((string)($value ?? ''));
if ($text !== '') {
return $text;
}
}
return '';
}
}

View File

@@ -466,10 +466,12 @@ class OrdersController
'enterprise_order' => 'enterprise_push',
'customer_push' => 'enterprise_push',
'large_customer_push' => 'enterprise_push',
'manual' => 'manual_entry',
'manual_order' => 'manual_entry',
];
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push', 'manual_entry'], true) ? $sourceChannel : '';
}
private function sourceChannelText(string $sourceChannel): string
@@ -478,6 +480,7 @@ class OrdersController
'mini_program' => '小程序',
'h5' => 'H5',
'enterprise_push' => '大客户推送订单',
'manual_entry' => '后台补录订单',
default => '未知渠道',
};
}

View File

@@ -92,9 +92,8 @@ class ReportsController
$reportData = is_array($report) ? $report : $report->toArray();
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
$isZhongjian = (string)($reportData['service_provider'] ?? '') === 'zhongjian';
$verify = $isZhongjian ? [] : (Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: []);
$verify = $isZhongjian ? [] : $this->normalizeVerifyInfo($reportData, $verify);
$verify = Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: [];
$verify = $this->normalizeVerifyInfo($reportData, $verify);
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []);
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
@@ -130,9 +129,9 @@ class ReportsController
'risk_notice_text' => $payload['risk_notice_text'],
'verify_info' => [
'report_no' => $reportData['report_no'],
'verify_status' => $isZhongjian ? '' : ($verify['verify_status'] ?? 'valid'),
'verify_url' => $isZhongjian ? '' : ($verify['verify_url'] ?? ''),
'verify_qrcode_url' => $isZhongjian ? '' : ($verify['verify_qrcode_url'] ?? ''),
'verify_status' => $verify['verify_status'] ?? 'valid',
'verify_url' => $verify['verify_url'] ?? '',
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? '',
],
'file_info' => [
'pdf_url' => $pdfUrl,
@@ -218,9 +217,7 @@ class ReportsController
'verify_info' => sprintf(
'%s / %s',
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
($report['service_provider'] ?? '') === 'zhongjian'
? '中检报告'
: (($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
(($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
),
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
]);

View File

@@ -52,6 +52,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
{
return match (true) {
str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'],
str_starts_with($path, '/api/admin/manual-order/') => ['orders.manage', 'warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/orders') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/orders'),

View File

@@ -9,31 +9,21 @@ class FulfillmentFlowService
{
public function lookupInboundByTrackingNo(string $trackingNo): array
{
$trackingNo = trim($trackingNo);
if ($trackingNo === '') {
throw new \InvalidArgumentException('请先扫描寄入运单号');
}
$rows = Db::name('order_logistics')
->where('logistics_type', 'send_to_center')
->where('tracking_no', $trackingNo)
->select()
->toArray();
if (!$rows) {
throw new \RuntimeException('未匹配到订单,请核对寄入运单号', 404);
}
if (count($rows) > 1) {
throw new \RuntimeException('该运单号匹配到多笔订单,请人工核查后处理', 409);
}
return $this->formatOrderContext((int)$rows[0]['order_id']);
return $this->lookupInboundByInboundNo($trackingNo);
}
public function receiveInbound(string $trackingNo, string $tagNo, Request $request): array
public function lookupInboundByInboundNo(string $inboundNo): array
{
$match = $this->resolveInboundOrder($inboundNo);
return $this->formatOrderContext((int)$match['order_id']);
}
public function receiveInbound(string $inboundNo, string $tagNo, Request $request, mixed $attachments = []): array
{
$operator = $this->operator($request);
$context = $this->lookupInboundByTrackingNo($trackingNo);
$match = $this->resolveInboundOrder($inboundNo);
$context = $this->formatOrderContext((int)$match['order_id']);
$order = $context['order_info'];
$orderId = (int)$order['id'];
@@ -47,6 +37,12 @@ class FulfillmentFlowService
}
$now = date('Y-m-d H:i:s');
$inboundAttachments = (new AppraisalEvidenceService())->normalize($attachments, $request, true);
$attachmentCount = count($inboundAttachments);
$inboundRemark = $this->inboundMatchRemark($match);
if ($attachmentCount > 0) {
$inboundRemark .= sprintf(',已上传拆包附件 %d 个', $attachmentCount);
}
Db::startTrans();
try {
@@ -114,8 +110,8 @@ class FulfillmentFlowService
$logistics = Db::name('order_logistics')
->where('order_id', $orderId)
->where('logistics_type', 'send_to_center')
->where('tracking_no', trim($trackingNo))
->order('id', 'desc')
->when(($match['match_type'] ?? '') === 'tracking_no', fn ($query) => $query->where('tracking_no', (string)$match['match_no']))
->find();
if ($logistics) {
Db::name('order_logistics')->where('id', (int)$logistics['id'])->update([
@@ -140,7 +136,11 @@ class FulfillmentFlowService
]);
$this->insertTimeline($orderId, 'inbound_received', '已入仓待检', '仓管扫描寄入运单并完成物品入库。', $operator, $now);
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, '扫描寄入运单号入库', $now);
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, $inboundRemark, $now, [
'match_type' => (string)($match['match_type'] ?? ''),
'match_no' => (string)($match['match_no'] ?? ''),
'inbound_attachments' => $inboundAttachments,
]);
$this->insertFlowLog($flow, 'internal_tag_bound', '绑定内部流转挂牌', 'warehouse_received', 'warehouse_pending_inspection', 'warehouse_received', 'warehouse_pending_inspection', $operator, $tagNo, $now);
Db::commit();
@@ -149,7 +149,7 @@ class FulfillmentFlowService
throw $e;
}
return $this->formatOrderContext($orderId);
return $this->formatOrderContext($orderId, $request);
}
public function scanTransferForAppraisal(string $tagNo, Request $request): array
@@ -283,15 +283,10 @@ class FulfillmentFlowService
public function verifyReturnMaterialTag(string $tagNo, string $qrInput, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') === 'zhongjian') {
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
}
$report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
@@ -302,7 +297,13 @@ class FulfillmentFlowService
throw new \InvalidArgumentException('验真吊牌与当前订单报告不匹配');
}
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已扫描验真吊牌并确认报告信息。');
return array_merge($this->formatOrderContext((int)$flow['order_id'], $request), [
'return_verification' => [
'verified' => true,
'report_id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
],
]);
}
public function confirmZhongjianReturn(string $tagNo, Request $request): array
@@ -326,7 +327,44 @@ class FulfillmentFlowService
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
}
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request): array
public function confirmReturnReport(string $tagNo, int $reportId, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
$report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
}
if ((int)$report['id'] !== $reportId) {
throw new \InvalidArgumentException('确认的报告与当前订单报告不匹配');
}
if ((string)($flow['current_stage'] ?? '') === 'return_confirmed') {
return $this->formatOrderContext((int)$flow['order_id'], $request);
}
if (($flow['service_provider'] ?? '') === 'zhongjian') {
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find();
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
if (trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
}
$boundTag = (new MaterialTagService())->findBoundTagForReport((int)$report['id']);
if (!$boundTag) {
throw new \InvalidArgumentException('当前报告未绑定验真吊牌,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已核对验真吊牌与报告信息。');
}
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request, array $packingAttachments = []): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
@@ -342,6 +380,12 @@ class FulfillmentFlowService
if ($expressCompany === '' || $trackingNo === '') {
throw new \InvalidArgumentException('请填写回寄快递公司和运单号');
}
$packingAttachments = $this->normalizeAssetList($packingAttachments, $request);
foreach ($packingAttachments as $asset) {
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
throw new \InvalidArgumentException('打包装箱附件仅支持图片或视频');
}
}
$orderId = (int)$flow['order_id'];
$order = Db::name('orders')->where('id', $orderId)->find();
@@ -432,7 +476,9 @@ class FulfillmentFlowService
]);
$this->insertTimeline($orderId, 'return_shipped', $nodeText, $nodeDesc, $operator, $now);
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now);
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now, [
'packing_attachments' => $packingAttachments,
]);
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
'user_id' => (int)($order['user_id'] ?? 0),
@@ -547,18 +593,7 @@ class FulfillmentFlowService
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
'zhongjian_report_files' => $this->normalizeAssetList($this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null), $request),
] : null,
'flow_logs' => array_map(fn (array $log) => [
'id' => (int)$log['id'],
'action_code' => (string)$log['action_code'],
'action_text' => (string)$log['action_text'],
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
'operator_name' => (string)($log['operator_name'] ?? ''),
'remark' => (string)($log['remark'] ?? ''),
'created_at' => (string)($log['created_at'] ?? ''),
], $flowLogs),
'flow_logs' => array_map(fn (array $log) => $this->formatFlowLog($log, $request), $flowLogs),
];
}
@@ -649,7 +684,7 @@ class FulfillmentFlowService
]);
}
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now): void
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now, ?array $payload = null): void
{
Db::name('order_transfer_flow_logs')->insert([
'flow_id' => (int)$flow['id'],
@@ -665,11 +700,33 @@ class FulfillmentFlowService
'operator_id' => $operator['id'],
'operator_name' => $operator['name'],
'remark' => mb_substr($remark, 0, 500),
'payload_json' => null,
'payload_json' => $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : null,
'created_at' => $now,
]);
}
private function formatFlowLog(array $log, ?Request $request): array
{
$payload = $this->decodeJsonObject($log['payload_json'] ?? null);
$attachments = $this->normalizeAssetList($this->decodeJsonArray($payload['inbound_attachments'] ?? []), $request);
$packingAttachments = $this->normalizeAssetList($this->decodeJsonArray($payload['packing_attachments'] ?? []), $request);
return [
'id' => (int)$log['id'],
'action_code' => (string)$log['action_code'],
'action_text' => (string)$log['action_text'],
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
'operator_name' => (string)($log['operator_name'] ?? ''),
'remark' => (string)($log['remark'] ?? ''),
'created_at' => (string)($log['created_at'] ?? ''),
'inbound_attachments' => $attachments,
'packing_attachments' => $packingAttachments,
];
}
private function ensureTransferTag(string $tagNo, array $operator, string $now): array
{
$tag = Db::name('internal_transfer_tags')->where('tag_no', $tagNo)->find();
@@ -695,6 +752,89 @@ class FulfillmentFlowService
return Db::name('internal_transfer_tags')->where('id', $id)->find();
}
private function resolveInboundOrder(string $inboundNo): array
{
$inboundNo = trim($inboundNo);
if ($inboundNo === '') {
throw new \InvalidArgumentException('请先扫描快递单号或输入鉴定订单号');
}
$logisticsRows = Db::name('order_logistics')
->where('logistics_type', 'send_to_center')
->where('tracking_no', $inboundNo)
->select()
->toArray();
if ($logisticsRows) {
if (count($logisticsRows) > 1) {
throw new \RuntimeException('该快递单号匹配到多笔订单,请人工核查后处理', 409);
}
return [
'order_id' => (int)$logisticsRows[0]['order_id'],
'match_type' => 'tracking_no',
'match_no' => $inboundNo,
];
}
$orderRows = Db::name('orders')
->where(function ($builder) use ($inboundNo) {
$builder->whereRaw(
'(order_no = :order_no OR appraisal_no = :appraisal_no)',
[
'order_no' => $inboundNo,
'appraisal_no' => $inboundNo,
]
);
})
->field(['id', 'order_no', 'appraisal_no'])
->select()
->toArray();
if ($orderRows) {
if (count($orderRows) > 1) {
throw new \RuntimeException('该订单号匹配到多笔订单,请人工核查后处理', 409);
}
$order = $orderRows[0];
return [
'order_id' => (int)$order['id'],
'match_type' => $inboundNo === (string)($order['appraisal_no'] ?? '') ? 'appraisal_no' : 'order_no',
'match_no' => $inboundNo,
];
}
$externalRows = Db::name('enterprise_customer_order_refs')
->where('external_order_no', $inboundNo)
->field(['order_id'])
->select()
->toArray();
if ($externalRows) {
if (count($externalRows) > 1) {
throw new \RuntimeException('该外部订单号匹配到多笔订单,请人工核查后处理', 409);
}
return [
'order_id' => (int)$externalRows[0]['order_id'],
'match_type' => 'external_order_no',
'match_no' => $inboundNo,
];
}
throw new \RuntimeException('未匹配到待入库订单', 404);
}
private function inboundMatchRemark(array $match): string
{
$label = match ((string)($match['match_type'] ?? '')) {
'tracking_no' => '快递单号',
'appraisal_no' => '鉴定单号',
'order_no' => '订单号',
'external_order_no' => '外部订单号',
default => '入库编号',
};
return sprintf('扫描%s入库%s', $label, (string)($match['match_no'] ?? ''));
}
private function findActiveFlowByTagNo(string $tagNo): ?array
{
$tagNo = $this->normalizeTagNo($tagNo);
@@ -849,6 +989,7 @@ class FulfillmentFlowService
'mini_program' => '小程序',
'h5' => 'H5',
'enterprise_push' => '大客户推送订单',
'manual_entry' => '后台补录订单',
default => $sourceChannel ?: '未知渠道',
};
}
@@ -865,6 +1006,19 @@ class FulfillmentFlowService
return [];
}
private function decodeJsonObject(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function normalizeAssetList(array $files, ?Request $request): array
{
if (!$request) {

View File

@@ -352,9 +352,6 @@ class MaterialTagService
if (!$task) {
throw new \RuntimeException('任务不存在', 404);
}
if (($task['service_provider'] ?? '') === 'zhongjian') {
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
}
$report = Db::name('reports')
->where('order_id', (int)$task['order_id'])
->where('report_type', 'appraisal')

View File

@@ -195,6 +195,9 @@ Route::post('/api/admin/auth/logout', [AdminAuthController::class, 'logout']);
Route::get('/api/admin/dashboard', [AdminDashboardController::class, 'index']);
Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']);
Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']);
Route::get('/api/admin/manual-order/meta', [AdminOrdersController::class, 'manualOrderMeta']);
Route::post('/api/admin/manual-order/create', [AdminOrdersController::class, 'createManualOrder']);
Route::post('/api/admin/manual-order/file/upload', [AdminOrdersController::class, 'uploadManualOrderFile']);
Route::get('/api/admin/order/warehouse/options', [AdminOrdersController::class, 'warehouseOptions']);
Route::post('/api/admin/order/warehouse/reassign', [AdminOrdersController::class, 'reassignWarehouse']);
Route::post('/api/admin/order/logistics/receive', [AdminOrdersController::class, 'receiveLogistics']);
@@ -258,12 +261,15 @@ Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index'])
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']);
Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']);
Route::post('/api/admin/warehouse-workbench/inbound/evidence/upload', [AdminWarehouseWorkbenchController::class, 'uploadInboundEvidenceFile']);
Route::post('/api/admin/warehouse-workbench/return/packing/upload', [AdminWarehouseWorkbenchController::class, 'uploadReturnPackingFile']);
Route::get('/api/admin/warehouse-workbench/zhongjian/lookup', [AdminWarehouseWorkbenchController::class, 'zhongjianLookup']);
Route::post('/api/admin/warehouse-workbench/zhongjian/outbound', [AdminWarehouseWorkbenchController::class, 'zhongjianOutbound']);
Route::post('/api/admin/warehouse-workbench/zhongjian/inbound', [AdminWarehouseWorkbenchController::class, 'zhongjianInbound']);
Route::get('/api/admin/warehouse-workbench/return/lookup', [AdminWarehouseWorkbenchController::class, 'returnLookup']);
Route::post('/api/admin/warehouse-workbench/return/material-tag/verify', [AdminWarehouseWorkbenchController::class, 'verifyReturnMaterialTag']);
Route::post('/api/admin/warehouse-workbench/return/zhongjian/confirm', [AdminWarehouseWorkbenchController::class, 'confirmZhongjianReturn']);
Route::post('/api/admin/warehouse-workbench/return/report/confirm', [AdminWarehouseWorkbenchController::class, 'confirmReturnReport']);
Route::post('/api/admin/warehouse-workbench/return/ship', [AdminWarehouseWorkbenchController::class, 'shipReturn']);
Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']);
Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']);

View File

@@ -29,10 +29,6 @@ const zhongjianReportOtherAttachments = computed(() =>
);
function goVerify() {
if (isZhongjianReport.value) {
uni.showToast({ title: "中检报告不使用平台验真吊牌", icon: "none" });
return;
}
uni.navigateTo({ url: `/pages/verify/result?report_no=${detail.value.report_header.report_no}` });
}
@@ -266,7 +262,7 @@ onLoad(async (options) => {
</view>
</view>
<view v-if="!isZhongjianReport" class="section section-card">
<view class="section section-card">
<view class="section__title">报告凭证</view>
<view class="credential-box">
<view class="credential-box__qr">
@@ -285,7 +281,7 @@ onLoad(async (options) => {
<view v-if="isZhongjianReport" class="section section-card">
<view class="section__title">中检报告文件</view>
<view class="section__desc">中检订单不使用平台验真吊牌请以中检报告编号与报告文件为准</view>
<view class="section__desc">中检报告文件可在下方查看报告验真请以报告凭证与吊牌组合验真结果为准</view>
<view v-if="zhongjianReportImageAttachments.length" class="task-files" style="margin-top: 20rpx;">
<view

View File

@@ -36,6 +36,7 @@ export interface AdminOrderListItem {
source_customer_id: string;
order_status: string;
display_status: string;
internal_tag_no?: string;
warehouse_bucket?: string;
warehouse_bucket_text?: string;
estimated_finish_time: string;
@@ -43,6 +44,66 @@ export interface AdminOrderListItem {
created_at: string;
}
export interface AdminManualOrderMaterialItem {
item_code: string;
item_name: string;
is_required: boolean;
files: AdminFileAsset[];
}
export interface AdminManualOrderCreatePayload {
service_provider: string;
product_info: {
category_id: number;
brand_id: number;
product_name: string;
color: string;
size_spec: string;
serial_no: string;
};
extra_info: {
purchase_channel: string;
purchase_price: number;
usage_status: string;
condition_desc: string;
remark: string;
};
return_address: {
consignee: string;
mobile: string;
province: string;
city: string;
district: string;
detail_address: string;
};
materials: AdminManualOrderMaterialItem[];
}
export interface AdminManualOrderCreateResponse {
order_id: number;
order_no: string;
appraisal_no: string;
user_id: number;
next_status: "pending_shipping";
}
export interface AdminManualOrderMeta {
categories: Array<{
id: number;
name: string;
code: string;
supported_service_types: string[];
}>;
brands: Array<{
id: number;
name: string;
en_name: string;
code: string;
category_ids: number[];
supported_service_types: string[];
}>;
}
export interface AdminOrderDetail {
order_info: AdminOrderListItem & {
can_mark_received: boolean;
@@ -65,6 +126,10 @@ export interface AdminOrderDetail {
full_address: string;
};
logistics_info: null | Record<string, any>;
inbound_attachments: AdminFileAsset[];
transfer_flow: null | {
internal_tag_no: string;
};
return_logistics: null | Record<string, any>;
supplement_task: null | Record<string, any>;
report_summary: null | {
@@ -119,12 +184,15 @@ export interface AdminWarehouseWorkbenchContext {
tracking_status: string;
};
transfer_flow: null | {
id?: number;
internal_tag_no: string;
flow_status?: string;
current_stage: string;
current_stage_text: string;
current_location: string;
current_location_text: string;
return_confirmed_at?: string;
return_shipped_at?: string;
};
report_info: null | {
id: number;
@@ -140,7 +208,14 @@ export interface AdminWarehouseWorkbenchContext {
operator_name: string;
remark: string;
created_at: string;
inbound_attachments?: AdminFileAsset[];
packing_attachments?: AdminFileAsset[];
}>;
return_verification?: {
verified: boolean;
report_id: number;
report_no: string;
};
next_action?: string;
next_action_text?: string;
}
@@ -168,6 +243,7 @@ export interface AdminAppraisalTaskListItem {
sla_deadline: string;
is_overtime: boolean;
display_status: string;
internal_tag_no?: string;
}
export interface AdminAppraisalTaskDetail {
@@ -345,17 +421,32 @@ export const adminApi = {
getOrderDetail(id: number) {
return request<AdminOrderDetail>("/api/admin/order/detail", { params: { id } });
},
lookupWarehouseInbound(trackingNo: string) {
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/lookup", {
params: { tracking_no: trackingNo },
getManualOrderMeta() {
return request<AdminManualOrderMeta>("/api/admin/manual-order/meta");
},
createManualOrder(data: AdminManualOrderCreatePayload) {
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
method: "POST",
data: data as unknown as Record<string, unknown>,
});
},
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
uploadManualOrderFile(filePath: string) {
return uploadFile<AdminFileAsset>("/api/admin/manual-order/file/upload", filePath);
},
lookupWarehouseInbound(inboundNo: string) {
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/lookup", {
params: { inbound_no: inboundNo },
});
},
receiveWarehouseInbound(data: { inbound_no: string; internal_tag_no: string; inbound_attachments?: AdminFileAsset[] }) {
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/receive", {
method: "POST",
data,
});
},
uploadWarehouseInboundEvidenceFile(filePath: string) {
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/inbound/evidence/upload", filePath);
},
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
params: { internal_tag_no: internalTagNo },
@@ -384,13 +475,22 @@ export const adminApi = {
data,
});
},
confirmWarehouseReturnReport(data: { internal_tag_no: string; report_id: number }) {
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/report/confirm", {
method: "POST",
data,
});
},
confirmWarehouseReturnZhongjian(internalTagNo: string) {
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
method: "POST",
data: { internal_tag_no: internalTagNo },
});
},
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string }) {
uploadWarehouseReturnPackingFile(filePath: string) {
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/return/packing/upload", filePath);
},
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) {
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
method: "POST",
data,
@@ -417,7 +517,7 @@ export const adminApi = {
data,
});
},
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[]; qr_input: string }) {
return request<{ id: number; report: Record<string, any> }>("/api/admin/appraisal-task/zhongjian-report/save", {
method: "POST",
data,
@@ -429,13 +529,13 @@ export const adminApi = {
data,
});
},
uploadAppraisalEvidenceFile(filePath: string) {
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath);
uploadAppraisalEvidenceFile(filePath: string, taskId?: number) {
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath, taskId ? { task_id: taskId } : {});
},
deleteAppraisalEvidenceFile(fileUrl: string) {
deleteAppraisalEvidenceFile(fileUrl: string, taskId?: number) {
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
method: "POST",
data: { file_url: fileUrl },
data: { file_url: fileUrl, ...(taskId ? { task_id: taskId } : {}) },
});
},
getReports(params?: Record<string, string | number>) {

View File

@@ -36,11 +36,23 @@
"navigationBarTitleText": "订单详情"
}
},
{
"path": "pages/order/manual-create",
"style": {
"navigationBarTitleText": "补录订单"
}
},
{
"path": "pages/report/detail",
"style": {
"navigationBarTitleText": "报告详情"
}
},
{
"path": "pages/return-shipping/index",
"style": {
"navigationBarTitleText": "确认回寄"
}
}
],
"tabBar": {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { adminApi, type AdminOrderDetail } from "../../api/admin";
import { adminApi, type AdminFileAsset, type AdminOrderDetail } from "../../api/admin";
import { showErrorToast } from "../../utils/feedback";
const loading = ref(false);
@@ -9,10 +9,13 @@ const pageReady = ref(false);
const loadError = ref("");
const detail = ref<AdminOrderDetail | null>(null);
const orderId = ref(0);
const activeInboundVideo = ref<AdminFileAsset | null>(null);
const pageTitle = computed(() => detail.value?.order_info.order_no || "订单详情");
const timeline = computed(() => detail.value?.timeline || []);
const inboundAttachments = computed(() => detail.value?.inbound_attachments || []);
const internalTagNo = computed(() => detail.value?.transfer_flow?.internal_tag_no || detail.value?.order_info.internal_tag_no || "");
async function fetchDetail() {
if (!orderId.value) return;
@@ -47,6 +50,39 @@ function displayAddress(address?: { consignee?: string; mobile?: string; full_ad
return [address.consignee, address.mobile, address.full_address].filter(Boolean).join(" / ");
}
function isImageAsset(item: AdminFileAsset) {
return item.file_type === "image" || item.mime_type?.startsWith("image/");
}
function isVideoAsset(item: AdminFileAsset) {
return item.file_type === "video" || item.mime_type?.startsWith("video/");
}
function attachmentTypeLabel(item: AdminFileAsset) {
if (isImageAsset(item)) return "图片";
if (isVideoAsset(item)) return "视频";
return "附件";
}
function previewInboundAttachment(item: AdminFileAsset) {
if (isImageAsset(item)) {
const urls = inboundAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
uni.previewImage({ urls, current: item.file_url });
return;
}
if (isVideoAsset(item)) {
activeInboundVideo.value = item;
return;
}
uni.showToast({ title: "当前附件暂不支持预览", icon: "none" });
}
function closeInboundVideo() {
activeInboundVideo.value = null;
}
onLoad((options) => {
orderId.value = Number(options?.id || 0);
if (!orderId.value) {
@@ -98,6 +134,10 @@ onShow(() => {
<view class="meta-label">预计完成</view>
<view class="meta-value">{{ detail.order_info.estimated_finish_time || "-" }}</view>
</view>
<view v-if="internalTagNo" class="meta-item meta-item--wide">
<view class="meta-label">流转码编号</view>
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
</view>
</view>
</view>
@@ -141,6 +181,39 @@ onShow(() => {
</view>
</view>
<view v-if="inboundAttachments.length" class="card">
<view class="row attachment-card-head">
<view class="attachment-card-copy">
<view class="card-title">入库附件</view>
<view class="card-desc">仓管入库时提交的拆包图片和视频可点击预览</view>
</view>
<text class="tag attachment-count">{{ inboundAttachments.length }}</text>
</view>
<view class="attachment-grid">
<view v-for="item in inboundAttachments" :key="item.file_url" class="attachment-tile">
<view class="attachment-preview" @click="previewInboundAttachment(item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<video
v-else-if="isVideoAsset(item)"
class="attachment-thumb attachment-video-thumb"
:src="item.file_url"
:controls="false"
:muted="true"
:show-center-play-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<view v-else class="attachment-file-thumb">附件</view>
<view v-if="isVideoAsset(item)" class="attachment-play"></view>
</view>
<view class="attachment-meta">
<text class="attachment-name">{{ item.name || item.file_id }}</text>
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
</view>
</view>
</view>
</view>
<view v-if="detail.report_summary" class="card">
<view class="row">
<view>
@@ -177,6 +250,16 @@ onShow(() => {
</view>
</view>
</view>
<view v-if="activeInboundVideo" class="video-preview-mask" @click="closeInboundVideo">
<view class="video-preview-panel" @click.stop>
<view class="video-preview-head">
<text class="video-preview-title">{{ activeInboundVideo.name || "入库视频" }}</text>
<text class="video-preview-close" @click="closeInboundVideo">关闭</text>
</view>
<video class="video-preview-player" :src="activeInboundVideo.file_url" controls autoplay />
</view>
</view>
</template>
</view>
</template>
@@ -212,4 +295,166 @@ onShow(() => {
font-size: 24rpx;
line-height: 1.5;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 18rpx;
}
.meta-item--wide {
grid-column: 1 / -1;
}
.transfer-code-value {
color: var(--work-warning);
font-weight: 900;
word-break: break-all;
}
.attachment-card-head {
align-items: flex-start;
}
.attachment-card-copy {
flex: 1;
min-width: 0;
}
.attachment-count {
flex: 0 0 auto;
white-space: nowrap;
}
.attachment-tile {
min-width: 0;
}
.attachment-preview {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
}
.attachment-thumb {
display: block;
width: 100%;
height: 100%;
}
.attachment-video-thumb {
background: #202124;
}
.attachment-file-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 800;
}
.attachment-play {
position: absolute;
left: 50%;
top: 50%;
width: 54rpx;
height: 54rpx;
margin-left: -27rpx;
margin-top: -27rpx;
border-radius: 50%;
background: rgba(32, 33, 36, 0.72);
color: #ffffff;
font-size: 28rpx;
line-height: 54rpx;
text-align: center;
}
.attachment-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8rpx;
margin-top: 8rpx;
}
.attachment-name {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-type {
min-height: 34rpx;
padding: 0 10rpx;
border-radius: var(--work-radius-pill);
background: var(--work-info-soft);
color: var(--work-info);
font-size: 20rpx;
font-weight: 700;
line-height: 34rpx;
}
.video-preview-mask {
position: fixed;
z-index: 20;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(0, 0, 0, 0.58);
}
.video-preview-panel {
width: 100%;
overflow: hidden;
border-radius: var(--work-radius);
background: #ffffff;
}
.video-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 22rpx 24rpx;
}
.video-preview-title {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 28rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-preview-close {
flex: 0 0 auto;
color: var(--work-info);
font-size: 26rpx;
font-weight: 800;
}
.video-preview-player {
display: block;
width: 100%;
height: 58vh;
background: #000000;
}
</style>

View File

@@ -0,0 +1,414 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { adminApi, type AdminFileAsset, type AdminManualOrderCreatePayload, type AdminManualOrderMeta } from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
import { buildRegionPickerState, updateRegionPickerIndexes } from "../../utils/regions";
const loading = ref(false);
const submitting = ref(false);
const uploading = ref(false);
const meta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
const form = ref<AdminManualOrderCreatePayload>(createForm());
const purchasePriceInput = ref("");
const regionPickerIndexes = ref<[number, number, number]>([0, 0, 0]);
const providerOptions = [
{ label: "实物鉴定", value: "anxinyan" },
{ label: "中检鉴定", value: "zhongjian" },
];
const usageOptions = [
{ label: "未选择", value: "" },
{ label: "全新未使用", value: "new" },
{ label: "轻微使用痕迹", value: "light_use" },
{ label: "长期使用", value: "used" },
];
const providerIndex = computed(() => Math.max(0, providerOptions.findIndex((item) => item.value === form.value.service_provider)));
const categoryIndex = computed(() => Math.max(0, meta.value.categories.findIndex((item) => item.id === form.value.product_info.category_id)));
const brandOptions = computed(() => {
const categoryId = form.value.product_info.category_id;
const provider = form.value.service_provider;
return meta.value.brands.filter((item) => {
const categoryMatched = !categoryId || !item.category_ids.length || item.category_ids.includes(categoryId);
const providerMatched = !item.supported_service_types.length || item.supported_service_types.includes(provider);
return categoryMatched && providerMatched;
});
});
const brandIndex = computed(() => Math.max(0, brandOptions.value.findIndex((item) => item.id === form.value.product_info.brand_id)));
const usageIndex = computed(() => Math.max(0, usageOptions.findIndex((item) => item.value === form.value.extra_info.usage_status)));
const materialFiles = computed(() => form.value.materials[0].files);
const selectedRegionText = computed(() => {
const { province, city, district } = form.value.return_address;
return province && city && district ? `${province} / ${city} / ${district}` : "";
});
const regionPickerState = computed(() => buildRegionPickerState(regionPickerIndexes.value));
function createForm(): AdminManualOrderCreatePayload {
return {
service_provider: "anxinyan",
product_info: {
category_id: 0,
brand_id: 0,
product_name: "",
color: "",
size_spec: "",
serial_no: "",
},
extra_info: {
purchase_channel: "",
purchase_price: 0,
usage_status: "",
condition_desc: "",
remark: "",
},
return_address: {
consignee: "",
mobile: "",
province: "",
city: "",
district: "",
detail_address: "",
},
materials: [
{
item_code: "manual_initial",
item_name: "补录资料",
is_required: false,
files: [],
},
],
};
}
async function fetchMeta() {
loading.value = true;
try {
meta.value = await adminApi.getManualOrderMeta();
if (!form.value.product_info.category_id && meta.value.categories.length) {
form.value.product_info.category_id = meta.value.categories[0].id;
}
if (!form.value.product_info.brand_id && brandOptions.value.length) {
form.value.product_info.brand_id = brandOptions.value[0].id;
}
} catch (error) {
showErrorToast(error, "补录选项加载失败");
} finally {
loading.value = false;
}
}
function onProviderChange(event: any) {
const index = Number(event.detail?.value || 0);
form.value.service_provider = providerOptions[index]?.value || "anxinyan";
ensureBrandSelection();
}
function onCategoryChange(event: any) {
const index = Number(event.detail?.value || 0);
form.value.product_info.category_id = meta.value.categories[index]?.id || 0;
ensureBrandSelection();
}
function onBrandChange(event: any) {
const index = Number(event.detail?.value || 0);
form.value.product_info.brand_id = brandOptions.value[index]?.id || 0;
}
function onUsageChange(event: any) {
const index = Number(event.detail?.value || 0);
form.value.extra_info.usage_status = usageOptions[index]?.value || "";
}
function onPurchasePriceInput(event: any) {
purchasePriceInput.value = String(event.detail?.value ?? "");
}
function applyRegionSelection(selection: string[]) {
const [province = "", city = "", district = ""] = selection;
form.value.return_address.province = province;
form.value.return_address.city = city;
form.value.return_address.district = district;
}
function onRegionColumnChange(event: any) {
regionPickerIndexes.value = updateRegionPickerIndexes(regionPickerState.value.indexes, {
column: event?.detail?.column || 0,
value: event?.detail?.value || 0,
});
}
function onRegionChange(event: any) {
const indexes = event?.detail?.value || regionPickerState.value.indexes;
regionPickerIndexes.value = indexes;
applyRegionSelection(buildRegionPickerState(indexes).selection);
}
function ensureBrandSelection() {
const current = brandOptions.value.find((item) => item.id === form.value.product_info.brand_id);
form.value.product_info.brand_id = current?.id || brandOptions.value[0]?.id || 0;
}
function pickerText(options: Array<{ label?: string; name?: string }>, index: number, fallback: string) {
const item = options[index];
return item?.label || item?.name || fallback;
}
function validateForm() {
const product = form.value.product_info;
const address = form.value.return_address;
if (!product.category_id || !product.brand_id || !product.product_name.trim()) {
showInfoToast("请完整填写品类、品牌和商品名称");
return false;
}
if (!address.consignee.trim() || !address.mobile.trim() || !address.province.trim() || !address.city.trim() || !address.district.trim() || !address.detail_address.trim()) {
showInfoToast("请完整填写寄回信息");
return false;
}
return true;
}
async function chooseImageFiles() {
try {
const result = await uni.chooseImage({
count: 9,
sizeType: ["compressed"],
sourceType: ["album", "camera"],
});
if (!result.tempFilePaths?.length) return;
uploading.value = true;
for (const filePath of result.tempFilePaths) {
const asset = await adminApi.uploadManualOrderFile(filePath);
form.value.materials[0].files.push(asset);
}
showInfoToast("图片上传成功");
} catch (error) {
showErrorToast(error, "图片上传失败");
} finally {
uploading.value = false;
}
}
async function chooseVideoFile() {
try {
const result = await uni.chooseVideo({ sourceType: ["album", "camera"] });
if (!result.tempFilePath) return;
uploading.value = true;
const asset = await adminApi.uploadManualOrderFile(result.tempFilePath);
form.value.materials[0].files.push(asset);
showInfoToast("视频上传成功");
} catch (error) {
showErrorToast(error, "视频上传失败");
} finally {
uploading.value = false;
}
}
function removeFile(file: AdminFileAsset) {
form.value.materials[0].files = form.value.materials[0].files.filter((item) => item.file_url !== file.file_url);
}
async function submitManualOrder() {
if (!validateForm() || submitting.value || uploading.value) return;
submitting.value = true;
try {
const payload = JSON.parse(JSON.stringify(form.value)) as AdminManualOrderCreatePayload;
const purchasePrice = Number(purchasePriceInput.value.trim());
payload.extra_info.purchase_price = Number.isFinite(purchasePrice) && purchasePrice > 0 ? purchasePrice : 0;
const response = await withLoading("正在创建", () => adminApi.createManualOrder(payload));
showInfoToast(`已创建 ${response.order_no}`);
uni.redirectTo({ url: `/pages/order/detail?id=${response.order_id}` });
} catch (error) {
showErrorToast(error, "补录订单创建失败");
} finally {
submitting.value = false;
}
}
onLoad(() => {
void fetchMeta();
});
</script>
<template>
<view class="page">
<view class="hero">
<view class="eyebrow">仓管补录</view>
<view class="title">补录订单</view>
<view class="subtitle">创建后等待入库可用订单号或鉴定单号绑定内部流转挂牌</view>
</view>
<view v-if="loading" class="empty">正在加载</view>
<template v-else>
<view class="card stack">
<view class="card-title">订单与商品</view>
<picker :range="providerOptions" range-key="label" :value="providerIndex" @change="onProviderChange">
<view class="field picker-field">{{ pickerText(providerOptions, providerIndex, "选择服务类型") }}</view>
</picker>
<picker :range="meta.categories" range-key="name" :value="categoryIndex" @change="onCategoryChange">
<view class="field picker-field">{{ pickerText(meta.categories, categoryIndex, "选择品类") }}</view>
</picker>
<picker :range="brandOptions" range-key="name" :value="brandIndex" @change="onBrandChange">
<view class="field picker-field">{{ pickerText(brandOptions, brandIndex, "选择品牌") }}</view>
</picker>
<input v-model="form.product_info.product_name" class="field" placeholder="商品名称" />
<input v-model="form.product_info.color" class="field" placeholder="颜色,可选" />
<input v-model="form.product_info.size_spec" class="field" placeholder="规格 / 尺寸,可选" />
<input v-model="form.product_info.serial_no" class="field" placeholder="序列号,可选" />
</view>
<view class="card stack">
<view class="card-title">补充信息</view>
<view class="field-group">
<view class="field-label">购买渠道</view>
<input v-model="form.extra_info.purchase_channel" class="field" placeholder="请输入购买渠道,可选" />
</view>
<view class="field-group">
<view class="field-label">购买价格</view>
<input :value="purchasePriceInput" class="field" type="digit" placeholder="请输入购买价格,可选" @input="onPurchasePriceInput" />
</view>
<view class="field-group">
<view class="field-label">使用情况</view>
<picker :range="usageOptions" range-key="label" :value="usageIndex" @change="onUsageChange">
<view class="field picker-field">{{ pickerText(usageOptions, usageIndex, "请选择使用情况,可选") }}</view>
</picker>
</view>
<view class="field-group">
<view class="field-label">成色说明</view>
<textarea v-model="form.extra_info.condition_desc" class="textarea" placeholder="请输入成色说明,可选" />
</view>
<view class="field-group">
<view class="field-label">内部备注</view>
<textarea v-model="form.extra_info.remark" class="textarea" placeholder="请输入内部备注,可选" />
</view>
</view>
<view class="card stack">
<view class="card-title">寄回信息</view>
<input v-model="form.return_address.consignee" class="field" placeholder="收件人" />
<input v-model="form.return_address.mobile" class="field" type="number" placeholder="手机号,用于匹配用户" />
<picker
mode="multiSelector"
:range="regionPickerState.columns"
:value="regionPickerState.indexes"
@columnchange="onRegionColumnChange"
@change="onRegionChange"
>
<view class="field picker-field region-field">
<text v-if="selectedRegionText" class="region-field__value">{{ selectedRegionText }}</text>
<text v-else class="region-field__placeholder">请选择省 / / 区县</text>
<text class="region-field__arrow"></text>
</view>
</picker>
<input v-model="form.return_address.detail_address" class="field" placeholder="详细地址" />
</view>
<view class="card stack">
<view class="row">
<view>
<view class="card-title">初始资料</view>
<view class="card-desc">{{ materialFiles.length }} 个文件</view>
</view>
</view>
<view class="upload-actions">
<button class="btn btn--ghost" :disabled="uploading" @click="chooseImageFiles">{{ uploading ? "上传中" : "添加图片" }}</button>
<button class="btn btn--ghost" :disabled="uploading" @click="chooseVideoFile">{{ uploading ? "上传中" : "添加视频" }}</button>
</view>
<view v-if="materialFiles.length" class="file-list">
<view v-for="file in materialFiles" :key="file.file_url" class="file-item">
<text class="file-name">{{ file.name || file.file_id }}</text>
<text class="tag tag--danger" @click="removeFile(file)">移除</text>
</view>
</view>
</view>
<button class="btn btn--primary submit-button" :disabled="submitting || uploading" @click="submitManualOrder">
{{ submitting ? "创建中" : "创建补录订单" }}
</button>
</template>
</view>
</template>
<style scoped lang="scss">
.picker-field {
display: flex;
align-items: center;
}
.field-group {
display: grid;
gap: 10rpx;
}
.field-label {
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 700;
line-height: 1.4;
}
.region-field {
justify-content: space-between;
gap: 16rpx;
}
.region-field__value {
min-width: 0;
overflow: hidden;
color: var(--work-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.region-field__placeholder {
min-width: 0;
overflow: hidden;
color: var(--work-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.region-field__arrow {
width: 14rpx;
height: 14rpx;
flex: 0 0 14rpx;
border-right: 3rpx solid var(--work-text-soft);
border-bottom: 3rpx solid var(--work-text-soft);
transform: rotate(45deg);
}
.upload-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.file-list {
display: grid;
gap: 12rpx;
}
.file-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12rpx;
padding: 18rpx;
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
}
.file-name {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 24rpx;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.submit-button {
margin-top: 24rpx;
}
</style>

View File

@@ -2,15 +2,18 @@
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { adminApi, type AdminReportDetail } from "../../api/admin";
import { showErrorToast } from "../../utils/feedback";
import { showErrorToast, showInfoToast } from "../../utils/feedback";
const loading = ref(false);
const pageReady = ref(false);
const loadError = ref("");
const detail = ref<AdminReportDetail | null>(null);
const reportId = ref(0);
const returnInternalTagNo = ref("");
const returnConfirming = ref(false);
const isZhongjian = computed(() => detail.value?.report_header.service_provider === "zhongjian");
const isReturnReview = computed(() => Boolean(returnInternalTagNo.value && reportId.value));
function previewImage(urls: string[], current: string) {
if (!urls.length) return;
@@ -79,8 +82,43 @@ async function fetchDetail() {
}
}
function readQueryString(value: unknown) {
const raw = Array.isArray(value) ? value[0] : value;
const text = String(raw || "").trim();
try {
return decodeURIComponent(text);
} catch {
return text;
}
}
async function confirmReturnFromReport() {
const currentDetail = detail.value;
if (!currentDetail || !returnInternalTagNo.value) {
showInfoToast("缺少回寄流转码");
return;
}
returnConfirming.value = true;
try {
await adminApi.confirmWarehouseReturnReport({
internal_tag_no: returnInternalTagNo.value,
report_id: currentDetail.report_header.id || reportId.value,
});
showInfoToast("报告已确认");
uni.redirectTo({
url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(returnInternalTagNo.value)}`,
});
} catch (error) {
showErrorToast(error, "报告确认失败");
} finally {
returnConfirming.value = false;
}
}
onLoad((options) => {
reportId.value = Number(options?.id || 0);
returnInternalTagNo.value = readQueryString(options?.return_internal_tag_no);
if (!reportId.value) {
loadError.value = "缺少报告编号,无法查看详情。";
}
@@ -105,6 +143,14 @@ onShow(() => {
<view class="subtitle">{{ detail.report_header.report_no }}</view>
</view>
<view v-if="isReturnReview" class="card return-review-card">
<view class="card-title">回寄前报告核对</view>
<view class="card-desc">请核对报告编号结论附件和验真信息确认无误后进入回寄信息填写</view>
<button class="btn btn--primary main-action" :disabled="returnConfirming" @click="confirmReturnFromReport">
{{ returnConfirming ? "确认中" : "确认寄回" }}
</button>
</view>
<view class="card">
<view class="row">
<view>
@@ -238,4 +284,12 @@ onShow(() => {
display: grid;
gap: 14rpx;
}
.return-review-card {
border-color: var(--work-accent);
}
.main-action {
margin-top: 18rpx;
}
</style>

View File

@@ -0,0 +1,502 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { adminApi, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const internalTagNo = ref("");
const expressCompany = ref("");
const trackingNo = ref("");
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
const loading = ref(false);
const submitting = ref(false);
const uploading = ref(false);
const packingAttachments = ref<AdminFileAsset[]>([]);
const activeVideo = ref<AdminFileAsset | null>(null);
const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context";
const returnConfirmed = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
const canSubmit = computed(() =>
returnConfirmed.value &&
Boolean(expressCompany.value.trim()) &&
Boolean(trackingNo.value.trim()) &&
!uploading.value &&
!submitting.value,
);
function readQueryString(value: unknown) {
const raw = Array.isArray(value) ? value[0] : value;
const text = String(raw || "").trim();
try {
return decodeURIComponent(text);
} catch {
return text;
}
}
async function fetchContext() {
if (!internalTagNo.value) {
showInfoToast("缺少内部流转码");
return;
}
loading.value = true;
try {
context.value = await adminApi.lookupWarehouseReturn(internalTagNo.value);
if (!returnConfirmed.value) {
showInfoToast("请先完成报告确认");
}
} catch (error) {
context.value = null;
showErrorToast(error, "回寄订单加载失败");
} finally {
loading.value = false;
}
}
function scanTrackingNo() {
uni.scanCode({
scanType: ["barCode", "qrCode"],
success: (result) => {
trackingNo.value = String(result.result || "").trim();
},
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
});
}
function isImageAsset(item: AdminFileAsset) {
return item.file_type === "image" || item.mime_type?.startsWith("image/");
}
function isVideoAsset(item: AdminFileAsset) {
return item.file_type === "video" || item.mime_type?.startsWith("video/");
}
function previewAttachment(item: AdminFileAsset) {
if (isImageAsset(item)) {
const urls = packingAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
uni.previewImage({ urls, current: item.file_url });
return;
}
if (isVideoAsset(item)) {
activeVideo.value = item;
return;
}
showInfoToast("当前附件暂不支持预览");
}
function closeVideo() {
activeVideo.value = null;
}
function removeAttachment(fileUrl: string) {
packingAttachments.value = packingAttachments.value.filter((item) => item.file_url !== fileUrl);
showInfoToast("附件已移除");
}
async function choosePackingImage() {
try {
const result = await uni.chooseImage({
count: 9,
sizeType: ["compressed"],
sourceType: ["album", "camera"],
});
if (!result.tempFilePaths?.length) return;
uploading.value = true;
for (const filePath of result.tempFilePaths) {
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath);
packingAttachments.value.push(asset);
}
showInfoToast("图片上传成功");
} catch (error) {
showErrorToast(error, "图片上传失败");
} finally {
uploading.value = false;
}
}
async function choosePackingVideo() {
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
});
const filePath = result.tempFilePath;
if (!filePath) return;
uploading.value = true;
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath);
packingAttachments.value.push(asset);
showInfoToast("视频上传成功");
} catch (error) {
showErrorToast(error, "视频上传失败");
} finally {
uploading.value = false;
}
}
async function submitReturnShipping() {
if (!context.value) {
await fetchContext();
return;
}
if (!returnConfirmed.value) {
showInfoToast("请先完成报告确认");
return;
}
if (!expressCompany.value.trim() || !trackingNo.value.trim()) {
showInfoToast("请填写快递公司和运单号");
return;
}
if (uploading.value) {
showInfoToast("附件上传中,请稍后提交");
return;
}
submitting.value = true;
try {
context.value = await withLoading("正在提交", () =>
adminApi.shipWarehouseReturn({
internal_tag_no: internalTagNo.value,
express_company: expressCompany.value.trim(),
tracking_no: trackingNo.value.trim(),
packing_attachments: packingAttachments.value,
}),
);
showInfoToast("寄回流程已完成");
const payload = {
internal_tag_no: internalTagNo.value,
context: context.value,
};
uni.setStorageSync(RETURN_SHIPPED_STORAGE_KEY, payload);
uni.$emit("warehouse-return-shipped", payload);
packingAttachments.value = [];
setTimeout(() => {
uni.switchTab({ url: "/pages/scan/index" });
}, 800);
} catch (error) {
showErrorToast(error, "回寄提交失败");
} finally {
submitting.value = false;
}
}
onLoad((options) => {
internalTagNo.value = readQueryString(options?.internal_tag_no);
void fetchContext();
});
</script>
<template>
<view class="page">
<view class="hero">
<view class="eyebrow">回寄信息</view>
<view class="title">确认回寄</view>
<view class="subtitle">填写回寄运单并上传打包装箱图片或视频</view>
</view>
<view v-if="loading && !context" class="empty">正在加载回寄订单</view>
<template v-else>
<view v-if="context" class="card">
<view class="row">
<view>
<view class="card-title">{{ context.product_info.product_name || "待完善物品信息" }}</view>
<view class="card-desc">{{ context.order_info.order_no }} / {{ context.order_info.appraisal_no }}</view>
</view>
<text :class="['tag', returnConfirmed ? 'tag--success' : 'tag--warning']">
{{ returnConfirmed ? "报告已确认" : "待确认报告" }}
</text>
</view>
<view class="meta-grid">
<view class="meta-item">
<view class="meta-label">内部挂牌</view>
<view class="meta-value">{{ context.transfer_flow?.internal_tag_no || internalTagNo || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">流转阶段</view>
<view class="meta-value">{{ context.transfer_flow?.current_stage_text || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">报告编号</view>
<view class="meta-value">{{ context.report_info?.report_no || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">发布时间</view>
<view class="meta-value">{{ context.report_info?.publish_time || "-" }}</view>
</view>
</view>
<view v-if="context.return_address" class="return-box">
<view class="meta-label">寄回地址</view>
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
</view>
</view>
<view class="card">
<view class="card-title">快递单号</view>
<view class="card-desc">报告确认后登记回寄物流信息</view>
<input v-model="expressCompany" class="field form-field" placeholder="回寄快递公司,例如:顺丰速运" />
<view class="scan-control">
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
<button class="btn scan-button" @click="scanTrackingNo">扫码</button>
</view>
</view>
<view class="card">
<view class="card-title">打包装箱附件</view>
<view class="card-desc">支持上传打包装箱图片或视频便于留档核对</view>
<view v-if="packingAttachments.length" class="attachment-grid">
<view v-for="item in packingAttachments" :key="item.file_url" class="attachment-tile">
<view class="attachment-preview" @click="previewAttachment(item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<video
v-else-if="isVideoAsset(item)"
class="attachment-thumb attachment-video-thumb"
:src="item.file_url"
:controls="false"
:muted="true"
:show-center-play-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<view v-else class="attachment-file-thumb">文件</view>
<view v-if="isVideoAsset(item)" class="attachment-play"></view>
</view>
<view class="attachment-meta">
<text class="attachment-name">{{ item.name || item.file_id }}</text>
<text class="tag tag--danger attachment-remove" @click="removeAttachment(item.file_url)">移除</text>
</view>
</view>
</view>
<view class="upload-actions">
<button class="upload-button" :disabled="uploading" @click="choosePackingImage">
<text class="upload-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
</button>
<button class="upload-button" :disabled="uploading" @click="choosePackingVideo">
<text class="upload-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
</button>
</view>
</view>
<button class="btn btn--primary submit-button" :disabled="!canSubmit" @click="submitReturnShipping">
{{ submitting ? "提交中" : "提交寄回" }}
</button>
</template>
<view v-if="activeVideo" class="video-preview-mask" @click="closeVideo">
<view class="video-preview-panel" @click.stop>
<view class="video-preview-head">
<text class="video-preview-title">{{ activeVideo.name || "装箱视频" }}</text>
<text class="video-preview-close" @click="closeVideo">关闭</text>
</view>
<video class="video-preview-player" :src="activeVideo.file_url" controls autoplay />
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.form-field {
width: 100%;
margin-top: 18rpx;
}
.scan-control {
display: flex;
gap: 14rpx;
margin-top: 14rpx;
}
.scan-input {
flex: 1;
min-width: 0;
}
.scan-button {
width: 132rpx;
}
.submit-button {
margin-top: 22rpx;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 16rpx;
}
.attachment-tile {
min-width: 0;
}
.attachment-preview {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
}
.attachment-thumb {
width: 100%;
height: 100%;
display: block;
}
.attachment-video-thumb {
background: #202124;
}
.attachment-file-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 800;
}
.attachment-play {
position: absolute;
left: 50%;
top: 50%;
width: 54rpx;
height: 54rpx;
margin-left: -27rpx;
margin-top: -27rpx;
border-radius: 50%;
background: rgba(32, 33, 36, 0.72);
color: #ffffff;
font-size: 28rpx;
line-height: 54rpx;
text-align: center;
}
.attachment-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8rpx;
margin-top: 8rpx;
}
.attachment-name {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-remove {
min-height: 36rpx;
padding: 0 10rpx;
font-size: 20rpx;
}
.upload-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 16rpx;
}
.upload-button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12rpx;
min-width: 0;
min-height: 82rpx;
padding: 0 22rpx;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
color: var(--work-text);
font-size: 26rpx;
font-weight: 800;
}
.upload-button[disabled] {
opacity: 0.56;
}
.upload-symbol {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background: #ffffff;
color: var(--work-accent-deep);
font-size: 28rpx;
font-weight: 800;
line-height: 32rpx;
text-align: center;
}
.return-box {
margin-top: 20rpx;
padding: 18rpx;
border-radius: var(--work-radius-sm);
background: var(--work-warning-soft);
}
.video-preview-mask {
position: fixed;
inset: 0;
z-index: 99;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(0, 0, 0, 0.72);
}
.video-preview-panel {
width: 100%;
max-width: 720rpx;
overflow: hidden;
border-radius: var(--work-radius);
background: #111111;
}
.video-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 18rpx 22rpx;
color: #ffffff;
}
.video-preview-title {
min-width: 0;
overflow: hidden;
font-size: 26rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-preview-close {
color: #ffffff;
font-size: 24rpx;
font-weight: 800;
}
.video-preview-player {
width: 100%;
height: 420rpx;
display: block;
background: #000000;
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { adminApi, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
import {
getAdminInfo,
resolveWorkRole,
@@ -11,35 +11,58 @@ import {
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
type WarehouseMode = "inbound" | "outbound" | "lookup";
type ReturnShippedPayload = {
internal_tag_no?: string;
context?: AdminWarehouseWorkbenchContext;
};
const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context";
const role = ref<WorkRole>(resolveWorkRole());
const mode = ref<WarehouseMode>("inbound");
const scanValue = ref("");
const matchedInboundNo = ref("");
const internalTagNo = ref("");
const inboundAttachments = ref<AdminFileAsset[]>([]);
const materialQr = ref("");
const expressCompany = ref("");
const returnTrackingNo = ref("");
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
const loading = ref(false);
const actionLoading = ref(false);
const uploadingInbound = ref(false);
const activeInboundVideo = ref<AdminFileAsset | null>(null);
const isWarehouse = computed(() => role.value === "warehouse");
const roleLabel = computed(() => roleText(role.value));
const pageDesc = computed(() =>
isWarehouse.value ? "扫描快递单号或内部流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
isWarehouse.value ? "扫描包裹或流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
);
const primaryPlaceholder = computed(() => {
if (!isWarehouse.value) return "扫描内部流转码";
return mode.value === "inbound" ? "扫描寄入运单号" : "扫描内部流转挂牌";
return mode.value === "inbound" ? "扫描快递单号 / 输入鉴定订单号" : "扫描内部流转挂牌";
});
const canReceiveInbound = computed(() =>
mode.value === "inbound" &&
Boolean(context.value) &&
matchedInboundNo.value !== "" &&
matchedInboundNo.value === scanValue.value.trim() &&
context.value?.order_info.order_status === "pending_shipping" &&
context.value?.logistics_info?.tracking_status !== "received" &&
context.value?.transfer_flow?.current_stage !== "warehouse_received",
);
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
const returnFlowEnded = computed(() =>
context.value?.transfer_flow?.flow_status === "ended" ||
context.value?.transfer_flow?.current_stage === "return_shipped" ||
Boolean(context.value?.transfer_flow?.return_shipped_at),
);
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at) && !returnFlowEnded.value);
const outboundActionText = computed(() => {
if (actionLoading.value) return "提交中";
if (returnFlowEnded.value && !context.value?.next_action) return "寄回已完成";
if (canReturnShip.value && !context.value?.next_action) return "填写回寄信息";
return "确认操作";
});
function refreshRole() {
role.value = resolveWorkRole(getAdminInfo());
@@ -48,13 +71,50 @@ function refreshRole() {
function chooseMode(next: WarehouseMode) {
mode.value = next;
scanValue.value = "";
matchedInboundNo.value = "";
internalTagNo.value = "";
inboundAttachments.value = [];
materialQr.value = "";
expressCompany.value = "";
returnTrackingNo.value = "";
context.value = null;
}
function applyReturnShippedPayload(payload: ReturnShippedPayload | AdminWarehouseWorkbenchContext | null | undefined) {
if (!payload) return;
const maybeContext = payload as AdminWarehouseWorkbenchContext;
const nextContext = "order_info" in maybeContext ? maybeContext : (payload as ReturnShippedPayload).context;
const nextTagNo = "order_info" in maybeContext
? maybeContext.transfer_flow?.internal_tag_no || scanValue.value.trim()
: (payload as ReturnShippedPayload).internal_tag_no || nextContext?.transfer_flow?.internal_tag_no || scanValue.value.trim();
mode.value = "outbound";
if (nextTagNo) {
scanValue.value = nextTagNo;
}
if (nextContext) {
context.value = nextContext;
}
materialQr.value = "";
expressCompany.value = "";
returnTrackingNo.value = "";
}
function handleReturnShipped(payload: ReturnShippedPayload | AdminWarehouseWorkbenchContext) {
applyReturnShippedPayload(payload);
}
function syncReturnShippedStateFromStorage() {
try {
const payload = uni.getStorageSync(RETURN_SHIPPED_STORAGE_KEY) as ReturnShippedPayload | AdminWarehouseWorkbenchContext | "";
if (!payload) return;
uni.removeStorageSync(RETURN_SHIPPED_STORAGE_KEY);
applyReturnShippedPayload(payload);
} catch {
uni.removeStorageSync(RETURN_SHIPPED_STORAGE_KEY);
}
}
function applyScanResult(value: string) {
if (!value) return;
scanValue.value = value.trim();
@@ -69,8 +129,20 @@ function openScanner() {
});
}
function updateScanValue(event: unknown) {
const inputEvent = event as { detail?: { value?: unknown }; target?: { value?: unknown } };
const value = inputEvent.detail?.value ?? inputEvent.target?.value;
if (value !== undefined && value !== null) {
scanValue.value = String(value).trim();
}
}
function currentScanValue() {
return scanValue.value.trim();
}
async function handlePrimaryAction() {
if (!scanValue.value.trim()) {
if (!currentScanValue()) {
showInfoToast(primaryPlaceholder.value);
return;
}
@@ -90,33 +162,62 @@ async function handlePrimaryAction() {
}
async function lookupInbound() {
const inboundNo = currentScanValue();
if (!inboundNo) {
showInfoToast("请扫描快递单号或输入鉴定订单号");
return;
}
loading.value = true;
try {
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
if (matchedInboundNo.value !== inboundNo) {
internalTagNo.value = "";
inboundAttachments.value = [];
}
context.value = await adminApi.lookupWarehouseInbound(inboundNo);
matchedInboundNo.value = inboundNo;
showInfoToast("已匹配订单");
} catch (error) {
context.value = null;
showErrorToast(error, "入库查询失败");
showErrorToast(error, "未匹配到待入库订单");
} finally {
loading.value = false;
}
}
async function receiveInbound() {
if (!scanValue.value.trim() || !internalTagNo.value.trim()) {
showInfoToast("请填写寄入运单号和内部流转挂牌");
const inboundNo = currentScanValue();
if (!inboundNo) {
showInfoToast("请先扫描快递单号或输入鉴定订单号");
return;
}
if (!context.value) {
showInfoToast("请先匹配订单信息");
return;
}
if (matchedInboundNo.value !== inboundNo) {
showInfoToast("订单信息已变化,请重新匹配");
return;
}
if (!internalTagNo.value.trim()) {
showInfoToast("请扫描流转码挂牌");
return;
}
if (uploadingInbound.value) {
showInfoToast("附件上传中,请稍后提交");
return;
}
actionLoading.value = true;
try {
context.value = await withLoading("正在入库", () =>
adminApi.receiveWarehouseInbound({
tracking_no: scanValue.value.trim(),
inbound_no: inboundNo,
internal_tag_no: internalTagNo.value.trim(),
inbound_attachments: inboundAttachments.value,
}),
);
showInfoToast("入库完成");
internalTagNo.value = "";
inboundAttachments.value = [];
} catch (error) {
showErrorToast(error, "入库失败");
} finally {
@@ -124,6 +225,77 @@ async function receiveInbound() {
}
}
async function chooseInboundImage() {
try {
const result = await uni.chooseImage({
count: 9,
sizeType: ["compressed"],
sourceType: ["album", "camera"],
});
if (!result.tempFilePaths?.length) return;
uploadingInbound.value = true;
for (const filePath of result.tempFilePaths) {
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath);
inboundAttachments.value.push(asset);
}
showInfoToast("图片上传成功");
} catch (error) {
showErrorToast(error, "图片上传失败");
} finally {
uploadingInbound.value = false;
}
}
async function chooseInboundVideo() {
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
});
const filePath = result.tempFilePath;
if (!filePath) return;
uploadingInbound.value = true;
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath);
inboundAttachments.value.push(asset);
showInfoToast("视频上传成功");
} catch (error) {
showErrorToast(error, "视频上传失败");
} finally {
uploadingInbound.value = false;
}
}
function removeInboundAttachment(fileUrl: string) {
inboundAttachments.value = inboundAttachments.value.filter((item) => item.file_url !== fileUrl);
showInfoToast("附件已移除");
}
function isImageAsset(item: AdminFileAsset) {
return item.file_type === "image" || item.mime_type?.startsWith("image/");
}
function isVideoAsset(item: AdminFileAsset) {
return item.file_type === "video" || item.mime_type?.startsWith("video/");
}
function previewInboundAttachment(item: AdminFileAsset) {
if (isImageAsset(item)) {
const urls = inboundAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
uni.previewImage({ urls, current: item.file_url });
return;
}
if (isVideoAsset(item)) {
activeInboundVideo.value = item;
return;
}
showInfoToast("当前附件暂不支持预览");
}
function closeInboundVideo() {
activeInboundVideo.value = null;
}
async function lookupOutbound() {
loading.value = true;
try {
@@ -143,6 +315,19 @@ async function lookupOutbound() {
}
}
function openReturnReportReview() {
const reportId = Number(context.value?.report_info?.id || context.value?.return_verification?.report_id || 0);
const tagNo = scanValue.value.trim();
if (!reportId || !tagNo) {
showInfoToast("未找到可核对的报告");
return;
}
uni.navigateTo({
url: `/pages/report/detail?id=${reportId}&return_internal_tag_no=${encodeURIComponent(tagNo)}`,
});
}
async function submitOutboundAction() {
if (!context.value) {
await lookupOutbound();
@@ -160,9 +345,12 @@ async function submitOutboundAction() {
showInfoToast("送检入库完成");
return;
}
if (returnFlowEnded.value) {
showInfoToast("寄回流程已完成");
return;
}
if (context.value.order_info.service_provider === "zhongjian") {
context.value = await adminApi.confirmWarehouseReturnZhongjian(scanValue.value.trim());
showInfoToast("中检报告已确认");
openReturnReportReview();
return;
}
if (!canReturnShip.value) {
@@ -174,19 +362,11 @@ async function submitOutboundAction() {
internal_tag_no: scanValue.value.trim(),
qr_input: materialQr.value.trim(),
});
showInfoToast("验真吊牌已确认");
showInfoToast("验真吊牌匹配通过,请核对报告");
openReturnReportReview();
return;
}
if (!expressCompany.value.trim() || !returnTrackingNo.value.trim()) {
showInfoToast("请填写回寄快递和运单号");
return;
}
context.value = await adminApi.shipWarehouseReturn({
internal_tag_no: scanValue.value.trim(),
express_company: expressCompany.value.trim(),
tracking_no: returnTrackingNo.value.trim(),
});
showInfoToast("回寄运单已登记");
uni.navigateTo({ url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(scanValue.value.trim())}` });
} catch (error) {
showErrorToast(error, "出库操作失败");
} finally {
@@ -198,10 +378,10 @@ async function lookupAnyOrder() {
loading.value = true;
try {
try {
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
context.value = await adminApi.lookupWarehouseInbound(currentScanValue());
return;
} catch {
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
context.value = await adminApi.lookupWarehouseReturn(currentScanValue());
}
} catch (error) {
context.value = null;
@@ -244,7 +424,18 @@ function scanMaterialQr() {
});
}
onShow(refreshRole);
onLoad(() => {
uni.$on("warehouse-return-shipped", handleReturnShipped);
});
onShow(() => {
refreshRole();
syncReturnShippedStateFromStorage();
});
onUnload(() => {
uni.$off("warehouse-return-shipped", handleReturnShipped);
});
</script>
<template>
@@ -266,22 +457,56 @@ onShow(refreshRole);
<view class="card">
<view class="card-title">{{ primaryPlaceholder }}</view>
<view class="scan-control">
<input v-model="scanValue" class="field scan-input" :placeholder="primaryPlaceholder" @confirm="handlePrimaryAction" />
<input :value="scanValue" class="field scan-input" :placeholder="primaryPlaceholder" @input="updateScanValue" @confirm="handlePrimaryAction" />
<button class="btn scan-button" @click="openScanner">扫码</button>
</view>
<button class="btn btn--primary main-action" :disabled="loading" @click="handlePrimaryAction">
{{ loading ? "处理中" : isWarehouse ? "识别" : "打开工单" }}
{{ loading ? "处理中" : mode === 'inbound' && isWarehouse ? "匹配订单" : isWarehouse ? "识别" : "打开工单" }}
</button>
</view>
<view v-if="canReceiveInbound" class="card">
<view class="card-title">入库绑定</view>
<view class="card-title">扫描流转码挂牌绑定</view>
<view class="scan-control">
<input v-model="internalTagNo" class="field scan-input" placeholder="内部流转挂牌" />
<input v-model="internalTagNo" class="field scan-input" placeholder="扫描或输入流转挂牌" />
<button class="btn scan-button" @click="scanInternalTagInput">扫码</button>
</view>
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="receiveInbound">
{{ actionLoading ? "入库中" : "确认入库" }}
<view class="card-desc">拆包视频或图片附件可选上传</view>
<view v-if="inboundAttachments.length" class="attachment-grid">
<view v-for="item in inboundAttachments" :key="item.file_url" class="attachment-tile">
<view class="attachment-preview" @click="previewInboundAttachment(item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<video
v-else-if="isVideoAsset(item)"
class="attachment-thumb attachment-video-thumb"
:src="item.file_url"
:controls="false"
:muted="true"
:show-center-play-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<view v-else class="attachment-file-thumb">文件</view>
<view v-if="isVideoAsset(item)" class="attachment-play"></view>
</view>
<view class="attachment-meta">
<text class="attachment-name">{{ item.name || item.file_id }}</text>
<text class="tag tag--danger attachment-remove" @click="removeInboundAttachment(item.file_url)">移除</text>
</view>
</view>
</view>
<view class="upload-actions">
<button class="upload-button" :disabled="uploadingInbound" @click="chooseInboundImage">
<text class="upload-symbol">+</text>
<text>{{ uploadingInbound ? "上传中" : "添加图片" }}</text>
</button>
<button class="upload-button" :disabled="uploadingInbound" @click="chooseInboundVideo">
<text class="upload-symbol">+</text>
<text>{{ uploadingInbound ? "上传中" : "添加视频" }}</text>
</button>
</view>
<button class="btn btn--primary main-action" :disabled="actionLoading || uploadingInbound || !canReceiveInbound" @click="receiveInbound">
{{ actionLoading ? "入库中" : "提交入库完成" }}
</button>
</view>
@@ -290,16 +515,18 @@ onShow(refreshRole);
<view class="card-desc">
{{ context.next_action_text || (context.order_info.service_provider === 'zhongjian' ? '确认中检报告后回寄' : '确认验真吊牌后回寄') }}
</view>
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !context.next_action" class="scan-control">
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !returnFlowEnded && !context.next_action" class="scan-control">
<input v-model="materialQr" class="field scan-input" placeholder="验真吊牌二维码" />
<button class="btn scan-button" @click="scanMaterialQr">扫码</button>
</view>
<view v-if="canReturnShip && !context.next_action" class="ship-fields">
<input v-model="expressCompany" class="field" placeholder="回寄快递公司" />
<input v-model="returnTrackingNo" class="field" placeholder="回寄运单号" />
<view class="card-desc">报告已确认可进入回寄信息页填写快递单号并上传打包装箱附件</view>
</view>
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="submitOutboundAction">
{{ actionLoading ? "提交中" : "确认操作" }}
<view v-if="returnFlowEnded && !context.next_action" class="ship-fields">
<view class="card-desc">寄回流程已完成无需重复填写回寄信息</view>
</view>
<button class="btn btn--primary main-action" :disabled="actionLoading || (returnFlowEnded && !context.next_action)" @click="submitOutboundAction">
{{ outboundActionText }}
</button>
</view>
@@ -334,6 +561,16 @@ onShow(refreshRole);
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
</view>
</view>
<view v-if="activeInboundVideo" class="video-preview-mask" @click="closeInboundVideo">
<view class="video-preview-panel" @click.stop>
<view class="video-preview-head">
<text class="video-preview-title">{{ activeInboundVideo.name || "拆包视频" }}</text>
<text class="video-preview-close" @click="closeInboundVideo">关闭</text>
</view>
<video class="video-preview-player" :src="activeInboundVideo.file_url" controls autoplay />
</view>
</view>
</view>
</template>
@@ -357,6 +594,128 @@ onShow(refreshRole);
margin-top: 18rpx;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 16rpx;
}
.attachment-tile {
min-width: 0;
}
.attachment-preview {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
}
.attachment-thumb {
width: 100%;
height: 100%;
display: block;
}
.attachment-video-thumb {
background: #202124;
}
.attachment-file-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 800;
}
.attachment-play {
position: absolute;
left: 50%;
top: 50%;
width: 54rpx;
height: 54rpx;
margin-left: -27rpx;
margin-top: -27rpx;
border-radius: 50%;
background: rgba(32, 33, 36, 0.72);
color: #ffffff;
font-size: 28rpx;
line-height: 54rpx;
text-align: center;
}
.attachment-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8rpx;
margin-top: 8rpx;
}
.attachment-name {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-remove {
min-height: 36rpx;
padding: 0 10rpx;
font-size: 20rpx;
}
.upload-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 16rpx;
}
.upload-button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12rpx;
min-width: 0;
min-height: 82rpx;
padding: 0 22rpx;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
color: var(--work-text);
font-size: 26rpx;
font-weight: 800;
}
.upload-button[disabled] {
opacity: 0.56;
}
.upload-symbol {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background: #ffffff;
color: var(--work-accent-deep);
font-size: 28rpx;
font-weight: 800;
line-height: 32rpx;
text-align: center;
}
.ship-fields {
display: grid;
gap: 14rpx;
@@ -369,4 +728,54 @@ onShow(refreshRole);
border-radius: var(--work-radius-sm);
background: var(--work-warning-soft);
}
.video-preview-mask {
position: fixed;
inset: 0;
z-index: 99;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(0, 0, 0, 0.72);
}
.video-preview-panel {
width: 100%;
max-width: 720rpx;
overflow: hidden;
border-radius: var(--work-radius);
background: #111111;
}
.video-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 18rpx 22rpx;
color: #ffffff;
}
.video-preview-title {
min-width: 0;
overflow: hidden;
font-size: 26rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-preview-close {
color: #ffffff;
font-size: 24rpx;
font-weight: 800;
}
.video-preview-player {
width: 100%;
height: 420rpx;
display: block;
background: #000000;
}
</style>

View File

@@ -26,6 +26,7 @@ const internalRemark = ref("");
const zhongjianReportNo = ref("");
const zhongjianFiles = ref<AdminFileAsset[]>([]);
const evidenceFiles = ref<AdminFileAsset[]>([]);
const activePreviewVideo = ref<AdminFileAsset | null>(null);
const supplementForm = reactive({
reason: "",
deadline: "",
@@ -33,6 +34,11 @@ const supplementForm = reactive({
});
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
const isTaskReadonly = computed(() => {
const status = detail.value?.task_info.status || "";
return status === "submitted" || status === "completed";
});
const internalTagNo = computed(() => detail.value?.task_info.internal_tag_no || "");
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
@@ -129,10 +135,18 @@ async function fetchDetail() {
}
function addSupplementItem() {
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再编辑");
return;
}
supplementForm.items.push({ item_name: "", guide_text: "", is_required: true });
}
function removeSupplementItem(index: number) {
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再编辑");
return;
}
if (supplementForm.items.length === 1) {
supplementForm.items[0].item_name = "";
supplementForm.items[0].guide_text = "";
@@ -143,8 +157,12 @@ function removeSupplementItem(index: number) {
}
async function removeEvidenceFile(fileUrl: string) {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再删除附件");
return;
}
try {
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
evidenceFiles.value = evidenceFiles.value.filter((item) => item.file_url !== fileUrl);
showInfoToast("附件已删除");
} catch (error) {
@@ -153,8 +171,12 @@ async function removeEvidenceFile(fileUrl: string) {
}
async function removeZhongjianFile(fileUrl: string) {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再删除文件");
return;
}
try {
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
zhongjianFiles.value = zhongjianFiles.value.filter((item) => item.file_url !== fileUrl);
showInfoToast("文件已删除");
} catch (error) {
@@ -162,7 +184,41 @@ async function removeZhongjianFile(fileUrl: string) {
}
}
function isImageAsset(item: AdminFileAsset) {
return item.file_type === "image" || item.mime_type?.startsWith("image/");
}
function isVideoAsset(item: AdminFileAsset) {
return item.file_type === "video" || item.mime_type?.startsWith("video/");
}
function attachmentTypeLabel(item: AdminFileAsset) {
if (isImageAsset(item)) return "图片";
if (isVideoAsset(item)) return "视频";
return "附件";
}
function previewAttachment(files: AdminFileAsset[], item: AdminFileAsset) {
if (isImageAsset(item)) {
const urls = files.filter(isImageAsset).map((asset) => asset.file_url);
uni.previewImage({ urls, current: item.file_url });
return;
}
if (isVideoAsset(item)) {
activePreviewVideo.value = item;
return;
}
showInfoToast("当前附件暂不支持预览");
}
function closePreviewVideo() {
activePreviewVideo.value = null;
}
function updateTemplatePoint(index: number, key: "point_value" | "point_remark", value: string) {
if (isTaskReadonly.value) return;
const template = detail.value?.appraisal_template;
if (!template) return;
const current = template.key_points[index];
@@ -195,7 +251,90 @@ function returnToWorkOrders(message: string) {
}, 700);
}
function confirmPublishReport() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: "提交确认",
content: "是否已鉴定完成并确定发布报告?",
cancelText: "取消",
confirmText: "去绑定",
success: (result) => resolve(Boolean(result.confirm)),
fail: () => resolve(false),
});
});
}
function promptMaterialTagQrInput() {
return new Promise<string>((resolve, reject) => {
uni.showModal({
title: "绑定验真吊牌",
content: "本地预览无法直接扫码,请输入或粘贴吊牌二维码内容。",
editable: true,
placeholderText: "二维码内容 / 验真吊牌编号",
cancelText: "取消",
confirmText: "绑定",
success: (result) => {
if (!result.confirm) {
reject(new Error("已取消绑定验真吊牌"));
return;
}
const qrInput = String(result.content || "").trim();
if (!qrInput) {
reject(new Error("请输入验真吊牌二维码内容"));
return;
}
resolve(qrInput);
},
fail: () => reject(new Error("已取消绑定验真吊牌")),
});
});
}
function scanMaterialTagQr() {
return new Promise<string>((resolve, reject) => {
uni.scanCode({
scanType: ["barCode", "qrCode"],
success: (result) => {
const qrInput = String(result.result || "").trim();
if (!qrInput) {
reject(new Error("未识别到验真吊牌二维码"));
return;
}
resolve(qrInput);
},
fail: () => {
// #ifdef H5
promptMaterialTagQrInput().then(resolve).catch(reject);
// #endif
// #ifndef H5
reject(new Error("已取消绑定验真吊牌"));
// #endif
},
});
});
}
async function confirmAndScanMaterialTag() {
const confirmed = await confirmPublishReport();
if (!confirmed) {
return "";
}
try {
return await scanMaterialTagQr();
} catch (error) {
showErrorToast(error, "验真吊牌扫码失败");
return "";
}
}
async function chooseEvidenceImage() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传附件");
return;
}
try {
const result = await uni.chooseImage({
count: 9,
@@ -205,7 +344,7 @@ async function chooseEvidenceImage() {
if (!result.tempFilePaths?.length) return;
uploading.value = true;
for (const filePath of result.tempFilePaths) {
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
evidenceFiles.value.push(asset);
}
showInfoToast("图片上传成功");
@@ -217,6 +356,10 @@ async function chooseEvidenceImage() {
}
async function chooseEvidenceVideo() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传附件");
return;
}
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
@@ -224,7 +367,7 @@ async function chooseEvidenceVideo() {
const filePath = result.tempFilePath;
if (!filePath) return;
uploading.value = true;
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
evidenceFiles.value.push(asset);
showInfoToast("视频上传成功");
} catch (error) {
@@ -235,6 +378,10 @@ async function chooseEvidenceVideo() {
}
async function chooseZhongjianImage() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传文件");
return;
}
try {
const result = await uni.chooseImage({
count: 9,
@@ -244,7 +391,7 @@ async function chooseZhongjianImage() {
if (!result.tempFilePaths?.length) return;
uploading.value = true;
for (const filePath of result.tempFilePaths) {
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
zhongjianFiles.value.push(asset);
}
showInfoToast("图片上传成功");
@@ -256,6 +403,10 @@ async function chooseZhongjianImage() {
}
async function chooseZhongjianVideo() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传文件");
return;
}
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
@@ -263,7 +414,7 @@ async function chooseZhongjianVideo() {
const filePath = result.tempFilePath;
if (!filePath) return;
uploading.value = true;
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
zhongjianFiles.value.push(asset);
showInfoToast("视频上传成功");
} catch (error) {
@@ -275,6 +426,10 @@ async function chooseZhongjianVideo() {
async function submitResult(action: "save" | "submit") {
if (!detail.value) return;
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再提交");
return;
}
if (isZhongjian.value) {
showInfoToast("中检订单请切换到中检报告区");
activeSection.value = "zhongjian";
@@ -286,6 +441,11 @@ async function submitResult(action: "save" | "submit") {
return;
}
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : "";
if (action === "submit" && !qrInput) {
return;
}
submitting.value = true;
try {
const conditionPayload = showConditionFields.value
@@ -330,10 +490,11 @@ async function submitResult(action: "save" | "submit") {
internal_remark: internalRemark.value.trim(),
attachments: evidenceFiles.value,
key_points: templateKeyPointsPayload(),
...(qrInput ? { qr_input: qrInput } : {}),
}),
);
if (action === "submit") {
returnToWorkOrders("鉴定已提交,正在返回工单");
returnToWorkOrders("验真吊牌已绑定,报告已发布");
return;
}
showInfoToast("鉴定已保存");
@@ -347,6 +508,10 @@ async function submitResult(action: "save" | "submit") {
async function submitSupplement() {
if (!detail.value) return;
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再发起补资料");
return;
}
const items = supplementForm.items.filter((item) => item.item_name.trim());
if (!supplementForm.reason.trim()) {
showInfoToast("请先填写补资料原因");
@@ -380,6 +545,10 @@ async function submitSupplement() {
async function submitZhongjianReport() {
if (!detail.value) return;
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再提交");
return;
}
if (!zhongjianReportNo.value.trim()) {
showInfoToast("请填写中检报告编号");
return;
@@ -389,14 +558,20 @@ async function submitZhongjianReport() {
return;
}
const qrInput = await confirmAndScanMaterialTag();
if (!qrInput) {
return;
}
submitting.value = true;
try {
await adminApi.saveZhongjianAppraisalReport({
id: detail.value.task_info.id,
zhongjian_report_no: zhongjianReportNo.value.trim(),
report_files: zhongjianFiles.value,
qr_input: qrInput,
});
returnToWorkOrders("中检报告已提交,正在返回工单");
returnToWorkOrders("验真吊牌已绑定,报告已发布");
} catch (error) {
showErrorToast(error, "中检报告录入失败");
} finally {
@@ -418,7 +593,7 @@ onLoad((options) => {
});
onShow(() => {
if (taskId.value) {
if (taskId.value && !pageReady.value) {
void fetchDetail();
}
});
@@ -461,9 +636,17 @@ onShow(() => {
<view class="meta-label">报告摘要</view>
<view class="meta-value">{{ reportSummary || "-" }}</view>
</view>
<view v-if="internalTagNo" class="meta-item meta-item--wide">
<view class="meta-label">流转码编号</view>
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
</view>
</view>
</view>
<view v-if="isTaskReadonly" class="readonly-notice">
当前工单已完成鉴定内容和附件仅可查看
</view>
<view class="card">
<view class="segmented">
<view :class="['segment', activeSection === 'result' ? 'segment--active' : '']" @click="activeSection = 'result'">鉴定结论</view>
@@ -475,21 +658,21 @@ onShow(() => {
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
<view class="card-title">鉴定结论</view>
<view class="stack" style="margin-top: 18rpx">
<input v-model="resultText" class="field" placeholder="结论,例如:正品 / 存疑" />
<textarea v-model="resultDesc" class="textarea" placeholder="结论说明" />
<input v-model="resultText" class="field" :disabled="isTaskReadonly" placeholder="结论,例如:正品 / 存疑" />
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
<template v-if="showConditionFields">
<input v-model="conditionGrade" class="field" placeholder="成色评级" />
<textarea v-model="conditionDesc" class="textarea" placeholder="成色说明" />
<input v-model="conditionGrade" class="field" :disabled="isTaskReadonly" placeholder="成色评级" />
<textarea v-model="conditionDesc" class="textarea" :disabled="isTaskReadonly" placeholder="成色说明" />
</template>
<template v-if="showValuationFields">
<view class="meta-grid">
<input v-model="valuationMin" class="field" placeholder="最低估值" />
<input v-model="valuationMax" class="field" placeholder="最高估值" />
<input v-model="valuationMin" class="field" :disabled="isTaskReadonly" placeholder="最低估值" />
<input v-model="valuationMax" class="field" :disabled="isTaskReadonly" placeholder="最高估值" />
</view>
<textarea v-model="valuationDesc" class="textarea" placeholder="估值说明" />
<textarea v-model="valuationDesc" class="textarea" :disabled="isTaskReadonly" placeholder="估值说明" />
</template>
<textarea v-model="externalRemark" class="textarea" placeholder="对外备注" />
<textarea v-model="internalRemark" class="textarea" placeholder="内部备注" />
<textarea v-model="externalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="对外备注" />
<textarea v-model="internalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="内部备注" />
</view>
<view v-if="detail.appraisal_template?.key_points?.length" class="stack" style="margin-top: 20rpx">
@@ -502,12 +685,14 @@ onShow(() => {
<input
:value="item.point_value"
class="field"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 值`"
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
/>
<textarea
:value="item.point_remark"
class="textarea"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 说明`"
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
/>
@@ -515,15 +700,34 @@ onShow(() => {
</view>
<view class="card-desc evidence-title">证据附件</view>
<view v-if="evidenceFiles.length" class="list" style="margin-top: 14rpx">
<view v-for="item in evidenceFiles" :key="item.file_url" class="list-card">
<view class="row">
<view class="list-title">{{ item.name || item.file_id }}</view>
<text class="tag tag--danger" @click="removeEvidenceFile(item.file_url)">删除</text>
<view v-if="evidenceFiles.length" class="attachment-grid">
<view v-for="item in evidenceFiles" :key="item.file_url" class="attachment-tile">
<view class="attachment-preview" @click="previewAttachment(evidenceFiles, item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<video
v-else-if="isVideoAsset(item)"
class="attachment-thumb attachment-video-thumb"
:src="item.file_url"
:controls="false"
:muted="true"
:show-center-play-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
@click.stop="previewAttachment(evidenceFiles, item)"
/>
<view v-else class="attachment-file-thumb">附件</view>
<view v-if="isVideoAsset(item)" class="attachment-play"></view>
</view>
<view class="attachment-meta">
<view class="attachment-name">{{ item.name || item.file_id }}</view>
<view class="attachment-actions">
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeEvidenceFile(item.file_url)">删除</text>
</view>
</view>
</view>
</view>
<view class="upload-actions">
<view v-if="!isTaskReadonly" class="upload-actions">
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
@@ -534,7 +738,7 @@ onShow(() => {
</button>
</view>
<view class="form-actions">
<view v-if="!isTaskReadonly" class="form-actions">
<button class="form-action form-action--secondary" :disabled="submitting" @click="submitResult('save')">保存</button>
<button class="form-action form-action--primary" :disabled="submitting" @click="submitResult('submit')">提交</button>
</view>
@@ -543,19 +747,19 @@ onShow(() => {
<view v-else-if="activeSection === 'supplement'" class="card">
<view class="card-title">补资料</view>
<view class="stack" style="margin-top: 18rpx">
<textarea v-model="supplementForm.reason" class="textarea" placeholder="补资料原因" />
<input v-model="supplementForm.deadline" class="field" placeholder="截止时间(可选)" />
<textarea v-model="supplementForm.reason" class="textarea" :disabled="isTaskReadonly" placeholder="补资料原因" />
<input v-model="supplementForm.deadline" class="field" :disabled="isTaskReadonly" placeholder="截止时间(可选)" />
<view v-for="(item, index) in supplementForm.items" :key="index" class="stack">
<input v-model="item.item_name" class="field" placeholder="补资料项名称" />
<textarea v-model="item.guide_text" class="textarea" placeholder="补资料说明" />
<view class="row">
<input v-model="item.item_name" class="field" :disabled="isTaskReadonly" placeholder="补资料项名称" />
<textarea v-model="item.guide_text" class="textarea" :disabled="isTaskReadonly" placeholder="补资料说明" />
<view v-if="!isTaskReadonly" class="row">
<text class="tag" :class="item.is_required ? 'tag--warning' : ''" @click="item.is_required = !item.is_required">
{{ item.is_required ? "必传" : "选传" }}
</text>
<text class="tag tag--danger" @click="removeSupplementItem(index)">删除</text>
</view>
</view>
<view class="form-actions">
<view v-if="!isTaskReadonly" class="form-actions">
<button class="form-action form-action--secondary" @click="addSupplementItem">添加一项</button>
<button class="form-action form-action--primary" :disabled="supplementSubmitting" @click="submitSupplement">发起补资料</button>
</view>
@@ -565,16 +769,35 @@ onShow(() => {
<view v-else class="card">
<view class="card-title">中检报告</view>
<view class="stack" style="margin-top: 18rpx">
<input v-model="zhongjianReportNo" class="field" placeholder="中检报告编号" />
<view v-if="zhongjianFiles.length" class="list">
<view v-for="item in zhongjianFiles" :key="item.file_url" class="list-card">
<view class="row">
<view class="list-title">{{ item.name || item.file_id }}</view>
<text class="tag tag--danger" @click="removeZhongjianFile(item.file_url)">删除</text>
<input v-model="zhongjianReportNo" class="field" :disabled="isTaskReadonly" placeholder="中检报告编号" />
<view v-if="zhongjianFiles.length" class="attachment-grid">
<view v-for="item in zhongjianFiles" :key="item.file_url" class="attachment-tile">
<view class="attachment-preview" @click="previewAttachment(zhongjianFiles, item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<video
v-else-if="isVideoAsset(item)"
class="attachment-thumb attachment-video-thumb"
:src="item.file_url"
:controls="false"
:muted="true"
:show-center-play-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
@click.stop="previewAttachment(zhongjianFiles, item)"
/>
<view v-else class="attachment-file-thumb">附件</view>
<view v-if="isVideoAsset(item)" class="attachment-play"></view>
</view>
<view class="attachment-meta">
<view class="attachment-name">{{ item.name || item.file_id }}</view>
<view class="attachment-actions">
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeZhongjianFile(item.file_url)">删除</text>
</view>
</view>
</view>
</view>
<view class="upload-actions">
<view v-if="!isTaskReadonly" class="upload-actions">
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianImage">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
@@ -584,13 +807,23 @@ onShow(() => {
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
</button>
</view>
<view class="form-actions" :class="detail.report_summary?.id ? '' : 'form-actions--single'">
<button class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交并发布</button>
<view v-if="!isTaskReadonly || detail.report_summary?.id" class="form-actions" :class="detail.report_summary?.id && !isTaskReadonly ? '' : 'form-actions--single'">
<button v-if="!isTaskReadonly" class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交并发布</button>
<button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
</view>
</view>
</view>
<view v-if="activePreviewVideo" class="video-preview-mask" @click="closePreviewVideo">
<view class="video-preview-panel" @click.stop>
<view class="video-preview-head">
<text class="video-preview-title">{{ activePreviewVideo.name || "附件视频" }}</text>
<text class="video-preview-close" @click="closePreviewVideo">关闭</text>
</view>
<video class="video-preview-player" :src="activePreviewVideo.file_url" controls autoplay />
</view>
</view>
<view class="card">
<view class="card-title">任务信息</view>
<view class="meta-grid">
@@ -610,6 +843,10 @@ onShow(() => {
<view class="meta-label">处理人</view>
<view class="meta-value">{{ detail.task_info.assignee_name }}</view>
</view>
<view v-if="internalTagNo" class="meta-item meta-item--wide">
<view class="meta-label">流转码编号</view>
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
</view>
</view>
</view>
</template>
@@ -622,12 +859,137 @@ onShow(() => {
gap: 14rpx;
}
.readonly-notice {
margin: -6rpx 0 18rpx;
padding: 18rpx 22rpx;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 700;
line-height: 1.5;
}
.meta-item--wide {
grid-column: 1 / -1;
}
.transfer-code-value {
color: var(--work-warning);
font-weight: 900;
word-break: break-all;
}
.evidence-title {
margin-top: 24rpx;
color: var(--work-text);
font-weight: 800;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 14rpx;
}
.attachment-tile {
min-width: 0;
}
.attachment-preview {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
}
.attachment-thumb {
display: block;
width: 100%;
height: 100%;
}
.attachment-video-thumb {
background: #202124;
}
.attachment-file-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 800;
}
.attachment-play {
position: absolute;
left: 50%;
top: 50%;
width: 54rpx;
height: 54rpx;
margin-left: -27rpx;
margin-top: -27rpx;
border-radius: 50%;
background: rgba(32, 33, 36, 0.72);
color: #ffffff;
font-size: 28rpx;
line-height: 54rpx;
text-align: center;
}
.attachment-meta {
min-width: 0;
margin-top: 8rpx;
}
.attachment-name {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-actions {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 6rpx;
}
.attachment-type,
.attachment-remove {
flex: 0 0 auto;
min-height: 34rpx;
padding: 0 10rpx;
border-radius: var(--work-radius-pill);
font-size: 20rpx;
font-weight: 700;
line-height: 34rpx;
white-space: nowrap;
}
.attachment-type {
background: var(--work-info-soft);
color: var(--work-info);
}
.attachment-remove {
background: var(--work-danger-soft);
color: var(--work-danger);
}
.upload-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -702,4 +1064,54 @@ onShow(() => {
background: var(--work-accent);
color: #ffffff;
}
.video-preview-mask {
position: fixed;
z-index: 20;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(0, 0, 0, 0.58);
}
.video-preview-panel {
width: 100%;
overflow: hidden;
border-radius: var(--work-radius);
background: #ffffff;
}
.video-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 22rpx 24rpx;
}
.video-preview-title {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 28rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-preview-close {
flex: 0 0 auto;
color: var(--work-info);
font-size: 26rpx;
font-weight: 800;
}
.video-preview-player {
display: block;
width: 100%;
height: 58vh;
background: #000000;
}
</style>

View File

@@ -18,7 +18,7 @@ const tasks = ref<AdminAppraisalTaskListItem[]>([]);
const isWarehouse = computed(() => role.value === "warehouse");
const title = computed(() => (isWarehouse.value ? "订单中心" : "鉴定工单"));
const desc = computed(() => (isWarehouse.value ? "仅展示在途、已入仓、待寄回订单。" : "处理我的鉴定待办和历史任务。"));
const desc = computed(() => (isWarehouse.value ? "仅展示待入库、在途、已入仓、待寄回订单。" : "处理我的鉴定待办和历史任务。"));
const listCount = computed(() => (isWarehouse.value ? orders.value.length : tasks.value.length));
const hasMore = computed(() => total.value > listCount.value);
@@ -26,6 +26,7 @@ const statusOptions = computed(() =>
isWarehouse.value
? [
{ label: "全部", value: "warehouse_active" },
{ label: "待入库", value: "warehouse_pending_inbound" },
{ label: "在途", value: "warehouse_in_transit" },
{ label: "已入仓", value: "warehouse_received" },
{ label: "待寄回", value: "warehouse_pending_return" },
@@ -112,6 +113,10 @@ function openOrder(item: AdminOrderListItem) {
uni.navigateTo({ url: `/pages/order/detail?id=${item.id}` });
}
function openManualOrderCreate() {
uni.navigateTo({ url: "/pages/order/manual-create" });
}
function openTask(item: AdminAppraisalTaskListItem) {
uni.navigateTo({ url: `/pages/task/detail?id=${item.id}` });
}
@@ -134,6 +139,7 @@ onReachBottom(loadMore);
</view>
<view class="card">
<button v-if="isWarehouse" class="btn btn--primary manual-entry" @click="openManualOrderCreate">补录订单</button>
<input v-model="keyword" class="field" :placeholder="isWarehouse ? '搜索订单号 / 鉴定单号 / 商品名称' : '搜索订单号 / 外部订单号 / 商品名称'" @confirm="handleSearch" />
<scroll-view class="status-scroll" scroll-x>
<view class="status-row">
@@ -159,6 +165,10 @@ onReachBottom(loadMore);
<text class="tag">{{ item.warehouse_bucket_text || item.display_status }}</text>
</view>
<view class="list-subtitle">{{ item.order_no }} / {{ item.appraisal_no }}</view>
<view v-if="item.internal_tag_no" class="transfer-code">
<text class="transfer-code__label">流转码</text>
<text class="transfer-code__value">{{ item.internal_tag_no }}</text>
</view>
<view class="list-footer">
<text class="tag">{{ item.service_provider_text }}</text>
<text class="list-subtitle">{{ item.created_at }}</text>
@@ -174,6 +184,10 @@ onReachBottom(loadMore);
<text :class="['tag', item.status === 'completed' ? 'tag--success' : item.status === 'returned' ? 'tag--warning' : '']">{{ item.status_text }}</text>
</view>
<view class="list-subtitle">{{ item.order_no }} / {{ item.external_order_no || item.appraisal_no }}</view>
<view v-if="item.internal_tag_no" class="transfer-code">
<text class="transfer-code__label">流转码</text>
<text class="transfer-code__value">{{ item.internal_tag_no }}</text>
</view>
<view class="list-footer">
<text class="tag">{{ item.service_provider_text }}</text>
<text class="list-subtitle">{{ item.assignee_name }}</text>
@@ -187,6 +201,10 @@ onReachBottom(loadMore);
</template>
<style scoped lang="scss">
.manual-entry {
margin-bottom: 18rpx;
}
.status-scroll {
width: 100%;
margin-top: 18rpx;
@@ -215,6 +233,38 @@ onReachBottom(loadMore);
color: #ffffff;
}
.transfer-code {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 42rpx;
margin-top: 12rpx;
overflow: hidden;
border-radius: var(--work-radius-pill);
background: var(--work-warning-soft);
}
.transfer-code__label {
flex: 0 0 auto;
padding: 0 12rpx;
color: var(--work-warning);
font-size: 22rpx;
font-weight: 800;
line-height: 42rpx;
}
.transfer-code__value {
min-width: 0;
padding-right: 14rpx;
overflow: hidden;
color: var(--work-text);
font-size: 22rpx;
font-weight: 800;
line-height: 42rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.load-more {
margin-top: 22rpx;
padding: 22rpx;

View File

@@ -0,0 +1,10 @@
# Regions Data
`pca.json` stores province/city/district data for the address picker.
- Source package: `lcn@7.2.2`
- Upstream source: 2024 Ministry of Civil Affairs county-level-and-above administrative division codes
- Data scope: 34 province-level entries, 342 prefecture-level entries, 2849 county-level entries
- License: MIT, inherited from `lcn`
To update this file later, replace `pca.json` with the latest `lcn` `data/pca.json` output and rerun `npm run type-check` plus `npm run build:h5`.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,106 @@
import regionSource from "../static/regions/pca.json";
export type RegionNode = {
code: string;
name: string;
children?: RegionNode[];
};
export type RegionSelection = [string, string, string];
export type RegionColumnIndex = 0 | 1 | 2;
export type RegionColumnChange = {
column: RegionColumnIndex;
value: number;
};
export type RegionPickerState = {
columns: [string[], string[], string[]];
indexes: [number, number, number];
selection: RegionSelection;
};
export const regionTree = regionSource as RegionNode[];
function getChildren(node?: RegionNode) {
return node?.children || [];
}
function clampIndex(index: number, length: number) {
if (length <= 0) {
return 0;
}
if (!Number.isFinite(index)) {
return 0;
}
return Math.min(Math.max(Math.trunc(index), 0), length - 1);
}
function names(nodes: RegionNode[]) {
return nodes.map((item) => item.name);
}
function firstAvailableSelection(province: RegionNode, city?: RegionNode, district?: RegionNode): RegionSelection {
const cityName = city?.name || province.name;
const districtName = district?.name || city?.name || province.name;
return [province.name, cityName, districtName];
}
export function findRegionIndexes(selection: Partial<Record<"province" | "city" | "district", string>>): [number, number, number] {
const provinceIndex = Math.max(
0,
regionTree.findIndex((province) => province.name === selection.province),
);
const province = regionTree[provinceIndex] || regionTree[0];
const cities = getChildren(province);
const cityIndex = Math.max(
0,
cities.findIndex((city) => city.name === selection.city),
);
const city = cities[cityIndex];
const districts = getChildren(city);
const districtIndex = Math.max(
0,
districts.findIndex((district) => district.name === selection.district),
);
return [provinceIndex, cityIndex, districtIndex];
}
export function buildRegionPickerState(indexes: [number, number, number]): RegionPickerState {
const provinceIndex = clampIndex(indexes[0], regionTree.length);
const province = regionTree[provinceIndex] || regionTree[0];
const cities = getChildren(province);
const cityIndex = clampIndex(indexes[1], cities.length);
const city = cities[cityIndex];
const districts = getChildren(city);
const districtIndex = clampIndex(indexes[2], districts.length);
const district = districts[districtIndex];
return {
columns: [
names(regionTree),
cities.length ? names(cities) : [province.name],
districts.length ? names(districts) : [city?.name || province.name],
],
indexes: [
provinceIndex,
cities.length ? cityIndex : 0,
districts.length ? districtIndex : 0,
],
selection: firstAvailableSelection(province, city, district),
};
}
export function updateRegionPickerIndexes(
indexes: [number, number, number],
change: RegionColumnChange,
): [number, number, number] {
if (change.column === 0) {
return [change.value, 0, 0];
}
if (change.column === 1) {
return [indexes[0], change.value, 0];
}
return [indexes[0], indexes[1], change.value];
}