Forráskód Böngészése

feat(web-admin): 出入库现场链接页对齐 Demo 信息密度与样式

补回取送货与导航,产品区卡片展示包装规格,单位使用后端已转义标签。

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 hónapja
szülő
commit
8b2ebe28e6
1 módosított fájl, 506 hozzáadás és 0 törlés
  1. 506 0
      web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue

+ 506 - 0
web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue

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