chore: prepare release build

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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