|
|
@@ -0,0 +1,506 @@
|
|
|
+<template>
|
|
|
+ <PublicMobileShell>
|
|
|
+ <div class="public-pl-site-page public-pl-transport-page">
|
|
|
+ <van-nav-bar :title="navTitle" :border="false" class="site-nav-bar" />
|
|
|
+
|
|
|
+ <van-loading v-if="loading" vertical class="page-loading">加载中...</van-loading>
|
|
|
+ <van-empty v-else-if="errorMessage" :description="errorMessage" />
|
|
|
+ <van-empty v-else-if="page?.voided" description="单据已作废" />
|
|
|
+ <template v-else-if="page">
|
|
|
+ <van-cell-group inset class="section-card">
|
|
|
+ <van-cell title="业务类型" :value="page.businessType || '—'" />
|
|
|
+ <van-cell title="日期" :value="page.planDate || '—'" />
|
|
|
+ <van-cell title="往来单位" :value="page.partnerCompany || '—'" />
|
|
|
+ <van-cell title="车牌" :value="page.vehicleNo || '—'" />
|
|
|
+ <van-cell title="计划号" :value="page.planNo || '—'" />
|
|
|
+ <van-cell title="单号" :value="page.orderNo || '—'" />
|
|
|
+ <van-cell title="状态" :value="statusLabel(page.status)" />
|
|
|
+ <van-cell
|
|
|
+ v-if="page.planRemark"
|
|
|
+ title="备注"
|
|
|
+ :value="page.planRemark"
|
|
|
+ class="multiline-cell"
|
|
|
+ />
|
|
|
+ </van-cell-group>
|
|
|
+
|
|
|
+ <van-cell-group inset class="section-card" title="取货信息">
|
|
|
+ <van-cell title="取货地址" class="address-cell multiline-cell">
|
|
|
+ <template #value>
|
|
|
+ <div class="address-cell__content">
|
|
|
+ <span class="address-cell__text">{{ page.pickupAddress || '—' }}</span>
|
|
|
+ <van-button
|
|
|
+ v-if="pickupNavUrl"
|
|
|
+ size="mini"
|
|
|
+ type="primary"
|
|
|
+ round
|
|
|
+ class="address-nav-btn"
|
|
|
+ @click="openNavigation(pickupNavUrl)"
|
|
|
+ >
|
|
|
+ 导航
|
|
|
+ </van-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </van-cell>
|
|
|
+ <van-cell title="取货时间" :value="formatDateTime(page.pickupTime)" />
|
|
|
+ <van-cell title="注意事项" :value="page.pickupNotes || '—'" class="multiline-cell" />
|
|
|
+ </van-cell-group>
|
|
|
+
|
|
|
+ <van-cell-group inset class="section-card" title="送货信息">
|
|
|
+ <van-cell title="送货地址" class="address-cell multiline-cell">
|
|
|
+ <template #value>
|
|
|
+ <div class="address-cell__content">
|
|
|
+ <span class="address-cell__text">{{ deliveryAddressDisplay }}</span>
|
|
|
+ <van-button
|
|
|
+ v-if="destinationNavUrl"
|
|
|
+ size="mini"
|
|
|
+ type="primary"
|
|
|
+ round
|
|
|
+ class="address-nav-btn"
|
|
|
+ @click="openNavigation(destinationNavUrl)"
|
|
|
+ >
|
|
|
+ 导航
|
|
|
+ </van-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </van-cell>
|
|
|
+ <van-cell title="送达时间" :value="formatDateTime(page.deliveryTime)" />
|
|
|
+ <van-cell title="注意事项" :value="page.destinationNotes || '—'" class="multiline-cell" />
|
|
|
+ </van-cell-group>
|
|
|
+
|
|
|
+ <van-cell-group inset class="section-card" title="产品实际数量">
|
|
|
+ <div
|
|
|
+ v-for="(item, idx) in editableItems"
|
|
|
+ :key="item.id"
|
|
|
+ class="product-card"
|
|
|
+ >
|
|
|
+ <div class="product-card__head">
|
|
|
+ <div class="product-card__name">{{ item.productName || '产品' }}</div>
|
|
|
+ <span class="product-card__badge">产品 {{ idx + 1 }}/{{ editableItems.length }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="meta-grid">
|
|
|
+ <div class="meta-item">
|
|
|
+ <div class="meta-item__label">规格型号</div>
|
|
|
+ <div class="meta-item__value">{{ item.specification || '—' }}</div>
|
|
|
+ </div>
|
|
|
+ <div class="meta-item">
|
|
|
+ <div class="meta-item__label">包装规格</div>
|
|
|
+ <div class="meta-item__value">{{ item.packageSpec || '—' }}</div>
|
|
|
+ </div>
|
|
|
+ <div class="meta-item meta-item--span">
|
|
|
+ <div class="meta-item__label">计划数量</div>
|
|
|
+ <div class="meta-item__value meta-item__value--plan">
|
|
|
+ {{ formatPlanQty(item) }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="actual-box">
|
|
|
+ <div class="actual-box__label">
|
|
|
+ <strong>实际数量</strong>
|
|
|
+ <span>请按现场实装填写</span>
|
|
|
+ </div>
|
|
|
+ <div class="actual-box__row">
|
|
|
+ <van-field
|
|
|
+ v-model="item.actualQtyText"
|
|
|
+ type="number"
|
|
|
+ class="actual-box__field"
|
|
|
+ placeholder="请输入"
|
|
|
+ :border="false"
|
|
|
+ />
|
|
|
+ <span class="actual-box__unit">{{ item.unit || '吨' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </van-cell-group>
|
|
|
+
|
|
|
+ <van-cell-group inset class="section-card" title="回执上传">
|
|
|
+ <div class="uploader-wrap">
|
|
|
+ <van-uploader
|
|
|
+ v-model="uploaderFiles"
|
|
|
+ multiple
|
|
|
+ :max-count="9"
|
|
|
+ :after-read="handleAfterRead"
|
|
|
+ :before-delete="handleBeforeDelete"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </van-cell-group>
|
|
|
+
|
|
|
+ <van-cell-group inset class="section-card" title="现场备注">
|
|
|
+ <van-field
|
|
|
+ v-model="feedbackRemark"
|
|
|
+ rows="4"
|
|
|
+ autosize
|
|
|
+ type="textarea"
|
|
|
+ maxlength="500"
|
|
|
+ show-word-limit
|
|
|
+ placeholder="填写现场备注"
|
|
|
+ />
|
|
|
+ </van-cell-group>
|
|
|
+
|
|
|
+ <van-cell-group v-if="page.siteFeedbackTime" inset class="section-card">
|
|
|
+ <van-cell title="上次保存" :value="formatDateTime(page.siteFeedbackTime)" />
|
|
|
+ </van-cell-group>
|
|
|
+
|
|
|
+ <footer class="page-footer">
|
|
|
+ <van-button type="primary" block round :loading="saving" @click="handleSave">
|
|
|
+ 提交
|
|
|
+ </van-button>
|
|
|
+ </footer>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </PublicMobileShell>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import type { UploaderFileListItem } from 'vant'
|
|
|
+ import { showFailToast, showSuccessToast } from 'vant'
|
|
|
+ import {
|
|
|
+ createPublicIoOrderApi,
|
|
|
+ type PublicIoOrderItem,
|
|
|
+ type PublicIoOrderPage
|
|
|
+ } from '@/api/pl/public-io-order'
|
|
|
+ import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
|
|
|
+ import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
|
|
|
+
|
|
|
+ import 'vant/es/toast/style'
|
|
|
+ import '@/styles/public-mobile-vant.scss'
|
|
|
+
|
|
|
+ const props = defineProps<{ kind: 'inbound' | 'outbound' }>()
|
|
|
+ const api = createPublicIoOrderApi(props.kind)
|
|
|
+ const route = useRoute()
|
|
|
+ const token = computed(() => String(route.params.token || ''))
|
|
|
+ const navTitle = computed(() =>
|
|
|
+ props.kind === 'inbound' ? '入库单 · 现场回执' : '出库单 · 现场回执'
|
|
|
+ )
|
|
|
+
|
|
|
+ const loading = ref(true)
|
|
|
+ const saving = ref(false)
|
|
|
+ const errorMessage = ref('')
|
|
|
+ const page = ref<PublicIoOrderPage | null>(null)
|
|
|
+ const feedbackRemark = ref('')
|
|
|
+ const feedbackImages = ref<string[]>([])
|
|
|
+ const uploaderFiles = ref<UploaderFileListItem[]>([])
|
|
|
+ const editableItems = ref<(PublicIoOrderItem & { actualQtyText: string })[]>([])
|
|
|
+
|
|
|
+ const deliveryAddressDisplay = computed(() => {
|
|
|
+ if (!page.value) return '—'
|
|
|
+ return page.value.destinationAddress || page.value.destinationName || '—'
|
|
|
+ })
|
|
|
+
|
|
|
+ const pickupNavUrl = computed(() => {
|
|
|
+ if (!page.value) return null
|
|
|
+ return buildAmapNavigationUrl(
|
|
|
+ page.value.pickupLng,
|
|
|
+ page.value.pickupLat,
|
|
|
+ page.value.pickupAddress || '取货点'
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ const destinationNavUrl = computed(() => {
|
|
|
+ if (!page.value) return null
|
|
|
+ return buildAmapNavigationUrl(
|
|
|
+ page.value.destinationLng,
|
|
|
+ page.value.destinationLat,
|
|
|
+ page.value.destinationAddress || page.value.destinationName || '送货点'
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ function openNavigation(url: string) {
|
|
|
+ window.location.href = url
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatDateTime(value?: string) {
|
|
|
+ if (!value) return '—'
|
|
|
+ const normalized = String(value).replace('T', ' ')
|
|
|
+ return normalized.length > 16 ? normalized.slice(0, 16) : normalized
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatPlanQty(item: PublicIoOrderItem) {
|
|
|
+ const qty = item.planQty
|
|
|
+ const unit = item.unit || ''
|
|
|
+ if (qty == null) return '—'
|
|
|
+ return unit ? `${qty} ${unit}` : String(qty)
|
|
|
+ }
|
|
|
+
|
|
|
+ function statusLabel(s?: string) {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ in_progress: '进行中',
|
|
|
+ completed: '已完成',
|
|
|
+ voided: '已作废'
|
|
|
+ }
|
|
|
+ return (s && map[s]) || s || '—'
|
|
|
+ }
|
|
|
+
|
|
|
+ function resolveFileUrl(url: string) {
|
|
|
+ if (!url) return ''
|
|
|
+ if (/^https?:\/\//i.test(url)) return url
|
|
|
+ const base = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
|
|
+ return base ? `${base}/${url.replace(/^\//, '')}` : url
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncUploaderFromImages() {
|
|
|
+ uploaderFiles.value = feedbackImages.value.map((url, index) => ({
|
|
|
+ url: resolveFileUrl(url),
|
|
|
+ isImage: /\.(png|jpe?g|gif|webp|bmp)$/i.test(url),
|
|
|
+ name: `附件${index + 1}`,
|
|
|
+ fileUrl: url
|
|
|
+ })) as UploaderFileListItem[]
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadPage() {
|
|
|
+ loading.value = true
|
|
|
+ errorMessage.value = ''
|
|
|
+ try {
|
|
|
+ const data = await api.getPage(token.value)
|
|
|
+ page.value = data
|
|
|
+ feedbackRemark.value = data.siteFeedbackRemark || ''
|
|
|
+ feedbackImages.value = [...(data.siteFeedbackImages || [])]
|
|
|
+ editableItems.value = (data.items || []).map(it => ({
|
|
|
+ ...it,
|
|
|
+ actualQtyText: it.actualQty != null ? String(it.actualQty) : ''
|
|
|
+ }))
|
|
|
+ syncUploaderFromImages()
|
|
|
+ } catch (e: any) {
|
|
|
+ errorMessage.value = e?.message || '链接无效或已失效'
|
|
|
+ page.value = null
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleAfterRead(item: UploaderFileListItem | UploaderFileListItem[]) {
|
|
|
+ const files = Array.isArray(item) ? item : [item]
|
|
|
+ for (const fileItem of files) {
|
|
|
+ if (!fileItem.file) continue
|
|
|
+ fileItem.status = 'uploading'
|
|
|
+ fileItem.message = '上传中...'
|
|
|
+ try {
|
|
|
+ const res = await api.uploadAttachment(token.value, fileItem.file as File)
|
|
|
+ if (res.fileUrl) {
|
|
|
+ feedbackImages.value.push(res.fileUrl)
|
|
|
+ ;(fileItem as UploaderFileListItem & { fileUrl?: string }).fileUrl = res.fileUrl
|
|
|
+ fileItem.url = resolveFileUrl(res.fileUrl)
|
|
|
+ fileItem.status = 'done'
|
|
|
+ fileItem.message = ''
|
|
|
+ }
|
|
|
+ } catch (e: any) {
|
|
|
+ fileItem.status = 'failed'
|
|
|
+ fileItem.message = e?.message || '上传失败'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleBeforeDelete(item: UploaderFileListItem) {
|
|
|
+ const fileUrl = (item as UploaderFileListItem & { fileUrl?: string }).fileUrl
|
|
|
+ if (fileUrl) {
|
|
|
+ feedbackImages.value = feedbackImages.value.filter(u => u !== fileUrl)
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleSave() {
|
|
|
+ saving.value = true
|
|
|
+ try {
|
|
|
+ await api.saveFeedback(token.value, {
|
|
|
+ remark: feedbackRemark.value,
|
|
|
+ images: feedbackImages.value,
|
|
|
+ items: editableItems.value.map(it => ({
|
|
|
+ id: it.id,
|
|
|
+ actualQty: Number(it.actualQtyText || 0)
|
|
|
+ }))
|
|
|
+ })
|
|
|
+ showSuccessToast('提交成功')
|
|
|
+ await loadPage()
|
|
|
+ } catch (e: any) {
|
|
|
+ showFailToast(e?.message || '提交失败')
|
|
|
+ } finally {
|
|
|
+ saving.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ watch(token, loadPage, { immediate: true })
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+ .page-loading {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 48px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .site-nav-bar {
|
|
|
+ margin: -12px -16px 12px;
|
|
|
+ border-radius: 0 0 12px 12px;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ :deep(.van-nav-bar) {
|
|
|
+ background: #059669;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.van-nav-bar__title) {
|
|
|
+ color: #fff;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .section-card {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .uploader-wrap {
|
|
|
+ padding: 8px 14px 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .page-footer {
|
|
|
+ padding: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .multiline-cell :deep(.van-cell__value) {
|
|
|
+ text-align: left;
|
|
|
+ line-height: 1.35;
|
|
|
+ white-space: normal;
|
|
|
+ }
|
|
|
+
|
|
|
+ .address-cell__content {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px 8px;
|
|
|
+ justify-content: flex-start;
|
|
|
+ }
|
|
|
+
|
|
|
+ .address-cell__text {
|
|
|
+ line-height: 1.35;
|
|
|
+ color: #1e293b;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .address-nav-btn {
|
|
|
+ flex-shrink: 0;
|
|
|
+ height: 22px;
|
|
|
+ padding: 0 8px;
|
|
|
+ font-size: 11px;
|
|
|
+ line-height: 22px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .product-card {
|
|
|
+ margin: 10px 12px;
|
|
|
+ padding: 14px;
|
|
|
+ border-radius: 12px;
|
|
|
+ background: linear-gradient(180deg, #f0fdf4 0%, #fff 48%);
|
|
|
+ border: 1px solid #d1fae5;
|
|
|
+ }
|
|
|
+
|
|
|
+ .product-card__head {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .product-card__name {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #0f172a;
|
|
|
+ line-height: 1.35;
|
|
|
+ }
|
|
|
+
|
|
|
+ .product-card__badge {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 600;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #d1fae5;
|
|
|
+ color: #047857;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-item {
|
|
|
+ background: rgb(255 255 255 / 85%);
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 8px 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-item--span {
|
|
|
+ grid-column: 1 / -1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-item__label {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #64748b;
|
|
|
+ margin-bottom: 2px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-item__value {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1e293b;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-item__value--plan {
|
|
|
+ color: #059669;
|
|
|
+ font-size: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .actual-box {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #a7f3d0;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .actual-box__label {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #64748b;
|
|
|
+
|
|
|
+ strong {
|
|
|
+ color: #1e293b;
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .actual-box__row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .actual-box__field {
|
|
|
+ flex: 1;
|
|
|
+ padding: 0;
|
|
|
+ background: #ecfdf5;
|
|
|
+ border: 1.5px solid #6ee7b7;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ :deep(.van-field__control) {
|
|
|
+ height: 40px;
|
|
|
+ padding: 0 12px;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #059669;
|
|
|
+ line-height: 40px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .actual-box__unit {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #059669;
|
|
|
+ min-width: 28px;
|
|
|
+ }
|
|
|
+</style>
|