Files
anxinyan/work-app/src/pages/order/detail.vue
2026-05-22 15:47:23 +08:00

544 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { adminApi, type AdminFileAsset, type AdminOrderDetail } from "../../api/admin";
import { showErrorToast, showInfoToast } from "../../utils/feedback";
const loading = ref(false);
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;
loading.value = true;
if (!pageReady.value) loadError.value = "";
try {
detail.value = await adminApi.getOrderDetail(orderId.value);
pageReady.value = true;
} catch (error) {
if (!pageReady.value) {
loadError.value = "订单详情加载失败,请稍后重试。";
}
showErrorToast(error, "订单详情加载失败");
} finally {
loading.value = false;
}
}
function openReportDetail() {
const reportId = Number(detail.value?.report_summary?.id || 0);
if (!reportId) return;
uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` });
}
function copyOrderNo() {
const orderNo = detail.value?.order_info.order_no || "";
if (!orderNo) return;
uni.setClipboardData({
data: orderNo,
success: () => showInfoToast("订单号已复制"),
fail: () => showInfoToast("复制失败,请重试"),
});
}
function formatMoney(value?: number) {
const amount = Number(value || 0);
return `¥${amount.toFixed(2)}`;
}
function displayAddress(address?: { consignee?: string; mobile?: string; full_address?: string } | null) {
if (!address) return "-";
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) {
loadError.value = "缺少订单编号,无法查看详情。";
}
});
onShow(() => {
if (orderId.value) {
void fetchDetail();
}
});
</script>
<template>
<view class="page">
<view v-if="!pageReady && loading" class="empty">正在加载订单详情</view>
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
<template v-else-if="detail">
<view class="hero">
<view class="eyebrow">订单详情</view>
<view class="order-title-row">
<view class="title order-title">{{ pageTitle }}</view>
<button class="copy-order-button" aria-label="复制订单号" hover-class="copy-order-button--active" @click.stop="copyOrderNo">
<view class="copy-order-button__icon">
<view class="copy-order-button__sheet copy-order-button__sheet--back"></view>
<view class="copy-order-button__sheet copy-order-button__sheet--front"></view>
</view>
</button>
</view>
<view class="subtitle">{{ detail.order_info.appraisal_no }}</view>
</view>
<view class="card">
<view class="row">
<view>
<view class="card-title">{{ detail.product_info.product_name || "待完善物品信息" }}</view>
<view class="card-desc">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
</view>
<text class="tag">{{ detail.order_info.display_status }}</text>
</view>
<view class="meta-grid">
<view class="meta-item">
<view class="meta-label">服务类型</view>
<view class="meta-value">{{ detail.order_info.service_provider_text }}</view>
</view>
<view class="meta-item">
<view class="meta-label">订单金额</view>
<view class="meta-value">{{ formatMoney(detail.order_info.pay_amount) }}</view>
</view>
<view class="meta-item">
<view class="meta-label">创建时间</view>
<view class="meta-value">{{ detail.order_info.created_at || "-" }}</view>
</view>
<view class="meta-item">
<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>
<view class="card">
<view class="card-title">物品信息</view>
<view class="meta-grid">
<view class="meta-item">
<view class="meta-label">商品名称</view>
<view class="meta-value">{{ detail.product_info.product_name || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">品类 / 品牌</view>
<view class="meta-value">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">颜色 / 规格</view>
<view class="meta-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">序列号</view>
<view class="meta-value">{{ detail.product_info.serial_no || "-" }}</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">物流与寄回</view>
<view class="stack" style="margin-top: 18rpx">
<view class="meta-item">
<view class="meta-label">寄送到中心</view>
<view class="meta-value">{{ detail.logistics_info ? `${detail.logistics_info.express_company || "-"} / ${detail.logistics_info.tracking_no || "-"}` : "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">寄回地址</view>
<view class="meta-value">{{ displayAddress(detail.return_address) }}</view>
</view>
<view class="meta-item">
<view class="meta-label">回寄运单</view>
<view class="meta-value">{{ detail.return_logistics ? `${detail.return_logistics.express_company || "-"} / ${detail.return_logistics.tracking_no || "-"}` : "-" }}</view>
</view>
</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" />
<template v-else-if="isVideoAsset(item)">
<image v-if="item.thumbnail_url" class="attachment-thumb" :src="item.thumbnail_url" mode="aspectFill" />
<view v-else class="attachment-video-thumb">
<text class="attachment-video-label">视频</text>
</view>
</template>
<view v-else class="attachment-file-thumb">附件</view>
<view v-if="isVideoAsset(item)" class="attachment-play" @click.stop="previewInboundAttachment(item)"></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>
<view class="card-title">报告摘要</view>
<view class="card-desc">{{ detail.report_summary.report_status_text || detail.report_summary.report_status }}</view>
</view>
<button class="btn btn--ghost" @click="openReportDetail">查看报告</button>
</view>
<view class="meta-grid">
<view class="meta-item">
<view class="meta-label">报告编号</view>
<view class="meta-value">{{ detail.report_summary.report_no }}</view>
</view>
<view class="meta-item">
<view class="meta-label">报告标题</view>
<view class="meta-value">{{ detail.report_summary.report_title }}</view>
</view>
<view class="meta-item">
<view class="meta-label">发布时间</view>
<view class="meta-value">{{ detail.report_summary.publish_time || "-" }}</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">流转时间线</view>
<view class="timeline">
<view v-for="item in timeline" :key="`${item.node_text}-${item.occurred_at}`" class="timeline-item">
<view class="timeline-item__head">
<view class="timeline-item__title">{{ item.node_text }}</view>
<view class="timeline-item__time">{{ item.occurred_at }}</view>
</view>
<view class="timeline-item__desc">{{ item.node_desc }}</view>
</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" :poster="activeInboundVideo.thumbnail_url || ''" controls autoplay />
</view>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.order-title-row {
display: flex;
align-items: flex-start;
gap: 14rpx;
margin-top: 18rpx;
}
.order-title {
flex: 1;
min-width: 0;
margin-top: 0;
overflow-wrap: anywhere;
word-break: break-all;
}
.copy-order-button {
position: relative;
flex: 0 0 auto;
width: 60rpx;
height: 60rpx;
margin-top: 2rpx;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: #ffffff;
}
.copy-order-button--active {
background: var(--work-card-muted);
}
.copy-order-button__icon {
position: absolute;
left: 50%;
top: 50%;
width: 36rpx;
height: 40rpx;
transform: translate(-50%, -50%);
}
.copy-order-button__sheet {
position: absolute;
width: 26rpx;
height: 30rpx;
border: 3rpx solid var(--work-text);
border-radius: 5rpx;
background: #ffffff;
}
.copy-order-button__sheet--back {
left: 1rpx;
top: 1rpx;
border-color: var(--work-text-soft);
}
.copy-order-button__sheet--front {
right: 1rpx;
bottom: 1rpx;
}
.timeline {
display: grid;
gap: 16rpx;
margin-top: 18rpx;
}
.timeline-item {
padding: 18rpx;
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
}
.timeline-item__head {
display: flex;
justify-content: space-between;
gap: 16rpx;
}
.timeline-item__title {
color: var(--work-text);
font-size: 28rpx;
font-weight: 700;
}
.timeline-item__time,
.timeline-item__desc {
color: var(--work-text-soft);
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,
.attachment-file-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.attachment-video-thumb {
background: linear-gradient(135deg, #f8fafc 0%, #e8edf3 100%);
color: var(--work-text);
}
.attachment-video-label {
font-size: 24rpx;
font-weight: 900;
}
.attachment-file-thumb {
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>