Explorar el Código

feat(web-admin): site public page brand qty chips entry

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 3 meses
padre
commit
6dde2cec07

+ 12 - 1
web-admin/src/api/pl/public-io-order.ts

@@ -1,5 +1,11 @@
 import { publicPlGet, publicPlPost, publicPlUpload } from '@/utils/http/public-pl'
 
+export interface BrandQtyItem {
+  brandId: string
+  brandName?: string
+  qty: number
+}
+
 export interface PublicIoOrderItem {
   id: string
   productNo?: string
@@ -10,6 +16,11 @@ export interface PublicIoOrderItem {
   actualQty?: number
   packageSpec?: string
   remark?: string
+  brandMode?: string | null
+  brandId?: string | null
+  brandName?: string | null
+  brandOptions?: { brandId: string; brandName: string }[]
+  brandQtys?: BrandQtyItem[]
 }
 
 export interface PublicIoOrderPage {
@@ -50,7 +61,7 @@ export function createPublicIoOrderApi(kind: 'inbound' | 'outbound') {
         status: 'in_progress' | 'completed' | 'voided'
         remark?: string
         images?: string[]
-        items: { id: string; actualQty: number }[]
+        items: { id: string; actualQty: number; brandQtys?: BrandQtyItem[] }[]
       }
     ) => publicPlPost<{ success: boolean }>(`${base}/${token}/feedback`, data),
     uploadAttachment: (token: string, file: File) =>

+ 345 - 0
web-admin/src/components/business/brand-qty-chips-field/index.vue

@@ -0,0 +1,345 @@
+<template>
+  <div v-if="brandMode" class="brand-qty-chips" :class="`brand-qty-chips--${variant}`">
+    <div class="brand-qty-chips__head">
+      <h4 class="brand-qty-chips__title">{{
+        brandMode === 'required' ? '指定品牌填量' : '选择品牌并填量'
+      }}</h4>
+      <span
+        class="brand-qty-chips__sum"
+        :class="{ 'brand-qty-chips__sum--ok': sumMatch, 'brand-qty-chips__sum--bad': !sumMatch }"
+      >
+        {{ sumLabel }}
+      </span>
+    </div>
+
+    <div v-if="brandMode === 'any'" class="brand-qty-chips__chips">
+      <button
+        v-for="opt in brandOptions"
+        :key="opt.brandId"
+        type="button"
+        class="brand-qty-chips__chip"
+        :class="{ 'brand-qty-chips__chip--on': isSelected(opt.brandId) }"
+        :disabled="disabled"
+        @click="toggleChip(opt)"
+      >
+        {{ opt.brandName }}
+        <span v-if="isSelected(opt.brandId)" class="brand-qty-chips__chip-qty">
+          {{ qtyOf(opt.brandId) || '' }}
+        </span>
+      </button>
+    </div>
+
+    <div v-else-if="brandMode === 'required'" class="brand-qty-chips__chips">
+      <span class="brand-qty-chips__chip brand-qty-chips__chip--on brand-qty-chips__chip--locked">
+        {{ requiredBrandLabel }}
+      </span>
+    </div>
+
+    <div class="brand-qty-chips__rows">
+      <div v-for="row in activeRows" :key="row.brandId" class="brand-qty-chips__row">
+        <span class="brand-qty-chips__row-label">{{ row.brandName }}</span>
+        <template v-if="variant === 'admin'">
+          <el-input-number
+            :model-value="row.qty"
+            :min="0"
+            :precision="3"
+            :step="0.001"
+            :disabled="disabled"
+            controls-position="right"
+            class="brand-qty-chips__admin-input"
+            @update:model-value="(val) => setQty(row.brandId, Number(val ?? 0))"
+          />
+        </template>
+        <template v-else>
+          <input
+            type="number"
+            inputmode="decimal"
+            step="0.001"
+            min="0"
+            class="brand-qty-chips__qty-input"
+            :value="row.qty === 0 ? '' : row.qty"
+            :disabled="disabled"
+            placeholder="0"
+            @input="onMobileQtyInput(row.brandId, $event)"
+          />
+        </template>
+        <span v-if="unit" class="brand-qty-chips__unit">{{ unit }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  export interface BrandQtyRow {
+    brandId: string
+    brandName: string
+    qty: number
+  }
+
+  export interface BrandOption {
+    brandId: string
+    brandName: string
+  }
+
+  const props = withDefaults(
+    defineProps<{
+      brandMode?: 'any' | 'required' | null
+      requiredBrandId?: string | null
+      brandOptions: BrandOption[]
+      modelValue: BrandQtyRow[]
+      disabled?: boolean
+      unit?: string
+      variant?: 'mobile' | 'admin'
+      actualQty?: number
+    }>(),
+    {
+      brandMode: null,
+      requiredBrandId: null,
+      disabled: false,
+      unit: '',
+      variant: 'mobile',
+      actualQty: undefined
+    }
+  )
+
+  const emit = defineEmits<{
+    'update:modelValue': [value: BrandQtyRow[]]
+    'update:actualQty': [value: number]
+  }>()
+
+  const requiredBrandLabel = computed(() => {
+    const fromOptions = props.brandOptions.find((o) => o.brandId === props.requiredBrandId)
+    if (fromOptions?.brandName) return fromOptions.brandName
+    const fromRows = props.modelValue.find((r) => r.brandId === props.requiredBrandId)
+    return fromRows?.brandName || '指定品牌'
+  })
+
+  const activeRows = computed(() => {
+    if (props.brandMode === 'required') {
+      const id = props.requiredBrandId || props.brandOptions[0]?.brandId || ''
+      const existing = props.modelValue.find((r) => r.brandId === id)
+      if (existing) return [existing]
+      const opt = props.brandOptions.find((o) => o.brandId === id)
+      return [{ brandId: id, brandName: opt?.brandName || requiredBrandLabel.value, qty: 0 }]
+    }
+    return props.modelValue
+  })
+
+  function isSelected(brandId: string) {
+    return props.modelValue.some((r) => r.brandId === brandId)
+  }
+
+  function qtyOf(brandId: string) {
+    return props.modelValue.find((r) => r.brandId === brandId)?.qty ?? 0
+  }
+
+  function sumRows(rows: BrandQtyRow[]) {
+    return rows.reduce((s, r) => s + (Number(r.qty) || 0), 0)
+  }
+
+  const totalQty = computed(() => Number(sumRows(props.modelValue).toFixed(3)))
+
+  const sumMatch = computed(() => {
+    if (props.actualQty == null) return true
+    return Math.abs(totalQty.value - Number(props.actualQty)) < 0.0005
+  })
+
+  const sumLabel = computed(() => {
+    if (props.actualQty == null) {
+      return `合计 ${totalQty.value}${props.unit ? ` ${props.unit}` : ''}`
+    }
+    if (sumMatch.value) {
+      return `合计 ${totalQty.value} = 实际数量 ✓`
+    }
+    return `合计 ${totalQty.value} ≠ 实际数量 ${props.actualQty}`
+  })
+
+  function emitRows(rows: BrandQtyRow[]) {
+    emit('update:modelValue', rows)
+    emit('update:actualQty', Number(sumRows(rows).toFixed(3)))
+  }
+
+  function toggleChip(opt: BrandOption) {
+    if (props.disabled || props.brandMode !== 'any') return
+    const selected = isSelected(opt.brandId)
+    if (selected) {
+      emitRows(props.modelValue.filter((r) => r.brandId !== opt.brandId))
+      return
+    }
+    emitRows([...props.modelValue, { brandId: opt.brandId, brandName: opt.brandName, qty: 0 }])
+  }
+
+  function setQty(brandId: string, qty: number) {
+    const next = props.modelValue.map((r) =>
+      r.brandId === brandId ? { ...r, qty: Math.max(0, qty) } : r
+    )
+    emitRows(next)
+  }
+
+  function onMobileQtyInput(brandId: string, e: Event) {
+    const raw = (e.target as HTMLInputElement).value
+    const qty = raw === '' ? 0 : Math.max(0, Number(raw) || 0)
+    setQty(brandId, qty)
+  }
+
+  watch(
+    () => [props.brandMode, props.requiredBrandId, props.brandOptions] as const,
+    () => {
+      if (props.brandMode !== 'required') return
+      const id = props.requiredBrandId || props.brandOptions[0]?.brandId
+      if (!id) return
+      const opt = props.brandOptions.find((o) => o.brandId === id)
+      const existing = props.modelValue.find((r) => r.brandId === id)
+      if (!existing) {
+        emitRows([
+          {
+            brandId: id,
+            brandName: opt?.brandName || requiredBrandLabel.value,
+            qty: 0
+          }
+        ])
+      }
+    },
+    { immediate: true }
+  )
+</script>
+
+<style scoped lang="scss">
+  .brand-qty-chips {
+    margin-top: 10px;
+    border-top: 1px dashed #bbf7d0;
+    padding-top: 10px;
+  }
+
+  .brand-qty-chips__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 8px;
+  }
+
+  .brand-qty-chips__title {
+    margin: 0;
+    font-size: 13px;
+    color: #047857;
+    font-weight: 600;
+  }
+
+  .brand-qty-chips__sum {
+    font-size: 11px;
+    color: #64748b;
+    text-align: right;
+    flex-shrink: 0;
+  }
+
+  .brand-qty-chips__sum--ok {
+    color: #059669;
+    font-weight: 600;
+  }
+
+  .brand-qty-chips__sum--bad {
+    color: #dc2626;
+    font-weight: 600;
+  }
+
+  .brand-qty-chips__chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-bottom: 8px;
+  }
+
+  .brand-qty-chips__chip {
+    border: 1px solid #cbd5e1;
+    background: #fff;
+    border-radius: 999px;
+    padding: 6px 10px;
+    font-size: 12px;
+    cursor: pointer;
+    line-height: 1.2;
+    color: #1e293b;
+  }
+
+  .brand-qty-chips__chip:disabled {
+    cursor: not-allowed;
+    opacity: 0.6;
+  }
+
+  .brand-qty-chips__chip--on {
+    border-color: #059669;
+    background: #ecfdf5;
+    color: #047857;
+    font-weight: 600;
+  }
+
+  .brand-qty-chips__chip--locked {
+    cursor: default;
+  }
+
+  .brand-qty-chips__chip-qty {
+    margin-left: 4px;
+    color: #64748b;
+    font-weight: 500;
+  }
+
+  .brand-qty-chips__rows {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+  }
+
+  .brand-qty-chips__row {
+    display: grid;
+    grid-template-columns: 72px 1fr auto;
+    gap: 6px;
+    align-items: center;
+  }
+
+  .brand-qty-chips__row-label {
+    font-size: 13px;
+    font-weight: 600;
+    padding-left: 4px;
+    color: #1e293b;
+  }
+
+  .brand-qty-chips__qty-input {
+    height: 36px;
+    border: 1.5px solid #6ee7b7;
+    border-radius: 8px;
+    padding: 0 10px;
+    font-size: 15px;
+    font-weight: 700;
+    color: #059669;
+    background: #ecfdf5;
+    width: 100%;
+    min-width: 0;
+
+    &:disabled {
+      opacity: 0.6;
+      cursor: not-allowed;
+    }
+
+    &:focus {
+      outline: none;
+      border-color: #059669;
+    }
+  }
+
+  .brand-qty-chips__unit {
+    font-size: 12px;
+    font-weight: 600;
+    color: #64748b;
+    min-width: 24px;
+  }
+
+  .brand-qty-chips--admin {
+    .brand-qty-chips__row {
+      grid-template-columns: 100px 1fr auto;
+    }
+
+    .brand-qty-chips__admin-input {
+      width: 100%;
+    }
+  }
+</style>

+ 108 - 15
web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue

@@ -74,11 +74,7 @@
         </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 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>
@@ -102,7 +98,8 @@
             <div class="actual-box">
               <div class="actual-box__label">
                 <strong>实际数量</strong>
-                <span>请按现场实装填写</span>
+                <span v-if="item.brandMode">由品牌明细合计</span>
+                <span v-else>请按现场实装填写</span>
               </div>
               <div class="actual-box__row">
                 <van-field
@@ -111,10 +108,22 @@
                   class="actual-box__field"
                   placeholder="请输入"
                   :border="false"
-                  :readonly="isReadOnly"
+                  :readonly="isReadOnly || Boolean(item.brandMode)"
                 />
                 <span class="actual-box__unit">{{ item.unit || '吨' }}</span>
               </div>
+              <BrandQtyChipsField
+                v-if="item.brandMode"
+                v-model="item.brandQtyRows"
+                :brand-mode="brandModeForField(item)"
+                :required-brand-id="item.brandId"
+                :brand-options="item.brandOptions || []"
+                :disabled="isReadOnly"
+                :unit="item.unit || '吨'"
+                variant="mobile"
+                :actual-qty="Number(item.actualQtyText || 0)"
+                @update:actual-qty="(val) => syncItemActualQty(item, val)"
+              />
             </div>
           </div>
         </van-cell-group>
@@ -175,9 +184,11 @@
   import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
   import {
     createPublicIoOrderApi,
+    type BrandQtyItem,
     type PublicIoOrderItem,
     type PublicIoOrderPage
   } from '@/api/pl/public-io-order'
+  import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
   import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
   import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
 
@@ -187,6 +198,11 @@
 
   type IoOrderStatus = 'in_progress' | 'completed' | 'voided'
 
+  type EditableIoOrderItem = PublicIoOrderItem & {
+    actualQtyText: string
+    brandQtyRows: BrandQtyItem[]
+  }
+
   const props = defineProps<{ kind: 'inbound' | 'outbound' }>()
   const api = createPublicIoOrderApi(props.kind)
   const route = useRoute()
@@ -202,7 +218,7 @@
   const feedbackRemark = ref('')
   const feedbackImages = ref<string[]>([])
   const uploaderFiles = ref<UploaderFileListItem[]>([])
-  const editableItems = ref<(PublicIoOrderItem & { actualQtyText: string })[]>([])
+  const editableItems = ref<EditableIoOrderItem[]>([])
   const selectedStatus = ref<IoOrderStatus>('in_progress')
   const isReadOnly = computed(() => Boolean(page.value?.voided))
 
@@ -271,6 +287,54 @@
     })) as UploaderFileListItem[]
   }
 
+  function mapBrandQtyRows(it: PublicIoOrderItem): BrandQtyItem[] {
+    const existing = (it.brandQtys || []).map((row) => ({
+      brandId: row.brandId,
+      brandName: row.brandName || '',
+      qty: Number(row.qty ?? 0)
+    }))
+    if (existing.length) return existing
+
+    if (it.brandMode === 'required') {
+      const brandId = it.brandId || it.brandOptions?.[0]?.brandId || ''
+      const brandName =
+        it.brandName || it.brandOptions?.find((o) => o.brandId === brandId)?.brandName || '指定品牌'
+      const seedQty = it.actualQty != null ? Number(it.actualQty) : 0
+      return brandId ? [{ brandId, brandName, qty: seedQty }] : []
+    }
+
+    return []
+  }
+
+  function brandModeForField(item: EditableIoOrderItem): 'any' | 'required' | null {
+    if (item.brandMode === 'any' || item.brandMode === 'required') return item.brandMode
+    return null
+  }
+
+  function syncItemActualQty(item: EditableIoOrderItem, val: number) {
+    item.actualQtyText = String(val)
+  }
+
+  function brandQtySum(rows: BrandQtyItem[]) {
+    return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
+  }
+
+  function validateBrandedItemsForCompleted(): string | null {
+    for (const item of editableItems.value) {
+      if (!item.brandMode) continue
+      const positiveRows = (item.brandQtyRows || []).filter((r) => Number(r.qty) > 0)
+      if (!positiveRows.length) {
+        return `「${item.productName || '产品'}」请至少填写一个品牌数量`
+      }
+      const total = Number(brandQtySum(item.brandQtyRows).toFixed(3))
+      const actual = Number(item.actualQtyText || 0)
+      if (Math.abs(total - actual) >= 0.0005) {
+        return `「${item.productName || '产品'}」品牌数量合计须等于实际数量`
+      }
+    }
+    return null
+  }
+
   async function loadPage() {
     loading.value = true
     errorMessage.value = ''
@@ -279,10 +343,22 @@
       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) : ''
-      }))
+      editableItems.value = (data.items || []).map((it) => {
+        const brandQtyRows = mapBrandQtyRows(it)
+        const actualFromBrand =
+          it.brandMode && brandQtyRows.length ? Number(brandQtySum(brandQtyRows).toFixed(3)) : null
+        return {
+          ...it,
+          brandOptions: it.brandOptions || [],
+          brandQtyRows,
+          actualQtyText:
+            actualFromBrand != null
+              ? String(actualFromBrand)
+              : it.actualQty != null
+                ? String(it.actualQty)
+                : ''
+        }
+      })
       selectedStatus.value = (
         ['in_progress', 'completed', 'voided'].includes(data.status || '')
           ? data.status
@@ -324,7 +400,7 @@
     if (isReadOnly.value) return false
     const fileUrl = (item as UploaderFileListItem & { fileUrl?: string }).fileUrl
     if (fileUrl) {
-      feedbackImages.value = feedbackImages.value.filter(u => u !== fileUrl)
+      feedbackImages.value = feedbackImages.value.filter((u) => u !== fileUrl)
     }
     return true
   }
@@ -341,15 +417,32 @@
         return
       }
     }
+    if (selectedStatus.value === 'completed') {
+      const brandErr = validateBrandedItemsForCompleted()
+      if (brandErr) {
+        showFailToast(brandErr)
+        return
+      }
+    }
+
     saving.value = true
     try {
       await api.saveFeedback(token.value, {
         status: selectedStatus.value,
         remark: feedbackRemark.value,
         images: feedbackImages.value,
-        items: editableItems.value.map(it => ({
+        items: editableItems.value.map((it) => ({
           id: it.id,
-          actualQty: Number(it.actualQtyText || 0)
+          actualQty: Number(it.actualQtyText || 0),
+          brandQtys: it.brandMode
+            ? (it.brandQtyRows || [])
+                .filter((row) => Number(row.qty) > 0)
+                .map((row) => ({
+                  brandId: row.brandId,
+                  brandName: row.brandName,
+                  qty: Number(row.qty)
+                }))
+            : undefined
         }))
       })
       showSuccessToast('提交成功')