chore: prepare release build
This commit is contained in:
@@ -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>) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
414
work-app/src/pages/order/manual-create.vue
Normal file
414
work-app/src/pages/order/manual-create.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
502
work-app/src/pages/return-shipping/index.vue
Normal file
502
work-app/src/pages/return-shipping/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
work-app/src/static/regions/README.md
Normal file
10
work-app/src/static/regions/README.md
Normal 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`.
|
||||
1
work-app/src/static/regions/pca.json
Normal file
1
work-app/src/static/regions/pca.json
Normal file
File diff suppressed because one or more lines are too long
106
work-app/src/utils/regions.ts
Normal file
106
work-app/src/utils/regions.ts
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user