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

@@ -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>