|
|
@@ -80,32 +80,77 @@
|
|
|
/>
|
|
|
</van-cell-group>
|
|
|
|
|
|
- <van-cell-group v-if="page.products.length" inset class="section-card" title="产品明细">
|
|
|
- <van-cell
|
|
|
- v-for="(item, idx) in page.products"
|
|
|
- :key="idx"
|
|
|
- :title="formatPublicTransportProductLine(item)"
|
|
|
- class="product-cell"
|
|
|
- />
|
|
|
- </van-cell-group>
|
|
|
-
|
|
|
- <van-cell-group inset class="section-card" title="回传数量">
|
|
|
- <div class="actual-box">
|
|
|
- <div class="actual-box__label">
|
|
|
- <strong>实际数量</strong>
|
|
|
- <span>请按实装填写,参考现场回执</span>
|
|
|
+ <van-cell-group
|
|
|
+ v-if="editableProducts.length"
|
|
|
+ inset
|
|
|
+ class="section-card"
|
|
|
+ title="产品回传数量"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ v-for="(item, idx) in editableProducts"
|
|
|
+ :key="item.planProductId"
|
|
|
+ class="product-card"
|
|
|
+ >
|
|
|
+ <div class="product-card__head">
|
|
|
+ <div class="product-card__name">{{ item.productName || '产品' }}</div>
|
|
|
+ <span class="product-card__badge">
|
|
|
+ 产品 {{ idx + 1 }}/{{ editableProducts.length }}
|
|
|
+ </span>
|
|
|
</div>
|
|
|
- <div class="actual-box__row">
|
|
|
- <van-field
|
|
|
- v-model="feedbackQtyText"
|
|
|
- type="number"
|
|
|
- class="actual-box__field"
|
|
|
- placeholder="请输入"
|
|
|
- :border="false"
|
|
|
+ <div class="meta-grid">
|
|
|
+ <div class="meta-item">
|
|
|
+ <div class="meta-item__label">规格型号</div>
|
|
|
+ <div class="meta-item__value">{{ item.model || '—' }}</div>
|
|
|
+ </div>
|
|
|
+ <div class="meta-item">
|
|
|
+ <div class="meta-item__label">包装规格</div>
|
|
|
+ <div class="meta-item__value">{{ item.packageSpec || '—' }}</div>
|
|
|
+ </div>
|
|
|
+ <div v-if="item.remark" class="meta-item meta-item--span">
|
|
|
+ <div class="meta-item__label">备注</div>
|
|
|
+ <div class="meta-item__value">{{ item.remark }}</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 v-if="item.brandMode">由品牌明细合计</span>
|
|
|
+ <span v-else>请按实装填写,参考现场回执</span>
|
|
|
+ </div>
|
|
|
+ <div class="actual-box__row">
|
|
|
+ <van-field
|
|
|
+ v-model="item.actualQtyText"
|
|
|
+ type="number"
|
|
|
+ class="actual-box__field"
|
|
|
+ placeholder="请输入"
|
|
|
+ :border="false"
|
|
|
+ :readonly="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 || []"
|
|
|
+ :unit="item.unit || '吨'"
|
|
|
+ variant="mobile"
|
|
|
+ :actual-qty="Number(item.actualQtyText || 0)"
|
|
|
+ @update:actual-qty="(val) => syncItemActualQty(item, val)"
|
|
|
/>
|
|
|
- <span class="actual-box__unit">{{ primaryQtyUnit }}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <div v-if="editableProducts.length > 1" class="total-box">
|
|
|
+ <span class="total-box__label">合计回传</span>
|
|
|
+ <span class="total-box__value">{{ totalFeedbackQty }}</span>
|
|
|
+ </div>
|
|
|
</van-cell-group>
|
|
|
|
|
|
<van-cell-group inset class="section-card" title="回执上传">
|
|
|
@@ -152,16 +197,24 @@
|
|
|
import { showFailToast, showSuccessToast } from 'vant'
|
|
|
import {
|
|
|
publicTransportApi,
|
|
|
+ type BrandQtyItem,
|
|
|
type PublicTransportPage,
|
|
|
- type PublicTransportPersonnel
|
|
|
+ type PublicTransportPersonnel,
|
|
|
+ type PublicTransportProduct
|
|
|
} from '@/api/pl/public-transport'
|
|
|
+ import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
|
|
|
import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
|
|
|
import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
|
|
|
- import { formatPublicTransportProductLine, formatPlPersonnelNameWithRole } from '@/utils/pl-display'
|
|
|
+ import { formatPlPersonnelNameWithRole } from '@/utils/pl-display'
|
|
|
|
|
|
import 'vant/es/toast/style'
|
|
|
import '@/styles/public-mobile-vant.scss'
|
|
|
|
|
|
+ type EditableTransportProduct = PublicTransportProduct & {
|
|
|
+ actualQtyText: string
|
|
|
+ brandQtyRows: BrandQtyItem[]
|
|
|
+ }
|
|
|
+
|
|
|
const route = useRoute()
|
|
|
const token = computed(() => String(route.params.token || ''))
|
|
|
|
|
|
@@ -170,13 +223,17 @@
|
|
|
const errorMessage = ref('')
|
|
|
const page = ref<PublicTransportPage | null>(null)
|
|
|
const feedbackRemark = ref('')
|
|
|
- const feedbackQtyText = ref('')
|
|
|
const feedbackImages = ref<string[]>([])
|
|
|
const uploaderFiles = ref<UploaderFileListItem[]>([])
|
|
|
+ const editableProducts = ref<EditableTransportProduct[]>([])
|
|
|
|
|
|
- const primaryQtyUnit = computed(() => {
|
|
|
- const unit = page.value?.products?.find((p) => p.unit)?.unit
|
|
|
- return unit || '吨'
|
|
|
+ const totalFeedbackQty = computed(() => {
|
|
|
+ const sum = editableProducts.value.reduce(
|
|
|
+ (acc, item) => acc + Number(item.actualQtyText || 0),
|
|
|
+ 0
|
|
|
+ )
|
|
|
+ const unit = editableProducts.value.find((p) => p.unit)?.unit || '吨'
|
|
|
+ return `${Number(sum.toFixed(3))} ${unit}`
|
|
|
})
|
|
|
|
|
|
function formatPersonnelLine(person: PublicTransportPersonnel) {
|
|
|
@@ -226,6 +283,13 @@
|
|
|
return normalized.length > 16 ? normalized.slice(0, 16) : normalized
|
|
|
}
|
|
|
|
|
|
+ function formatPlanQty(item: PublicTransportProduct) {
|
|
|
+ const qty = item.quantity
|
|
|
+ const unit = item.unit || ''
|
|
|
+ if (qty == null) return '—'
|
|
|
+ return unit ? `${qty} ${unit}` : String(qty)
|
|
|
+ }
|
|
|
+
|
|
|
function resolveFileUrl(url: string) {
|
|
|
if (!url) return ''
|
|
|
if (/^https?:\/\//i.test(url)) return url
|
|
|
@@ -242,6 +306,56 @@
|
|
|
})) as UploaderFileListItem[]
|
|
|
}
|
|
|
|
|
|
+ function mapBrandQtyRows(prod: PublicTransportProduct): BrandQtyItem[] {
|
|
|
+ const existing = (prod.brandQtys || []).map((row) => ({
|
|
|
+ brandId: row.brandId,
|
|
|
+ brandName: row.brandName || '',
|
|
|
+ qty: Number(row.qty ?? 0)
|
|
|
+ }))
|
|
|
+ if (existing.length) return existing
|
|
|
+
|
|
|
+ if (prod.brandMode === 'required') {
|
|
|
+ const brandId = prod.brandId || prod.brandOptions?.[0]?.brandId || ''
|
|
|
+ const brandName =
|
|
|
+ prod.brandName ||
|
|
|
+ prod.brandOptions?.find((o) => o.brandId === brandId)?.brandName ||
|
|
|
+ '指定品牌'
|
|
|
+ const seedQty = prod.actualQty != null ? Number(prod.actualQty) : 0
|
|
|
+ return brandId ? [{ brandId, brandName, qty: seedQty }] : []
|
|
|
+ }
|
|
|
+
|
|
|
+ return []
|
|
|
+ }
|
|
|
+
|
|
|
+ function brandModeForField(item: EditableTransportProduct): 'any' | 'required' | null {
|
|
|
+ if (item.brandMode === 'any' || item.brandMode === 'required') return item.brandMode
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncItemActualQty(item: EditableTransportProduct, val: number) {
|
|
|
+ item.actualQtyText = String(val)
|
|
|
+ }
|
|
|
+
|
|
|
+ function brandQtySum(rows: BrandQtyItem[]) {
|
|
|
+ return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
|
|
|
+ }
|
|
|
+
|
|
|
+ function validateBrandedProducts(): string | null {
|
|
|
+ for (const item of editableProducts.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 = ''
|
|
|
@@ -249,9 +363,20 @@
|
|
|
const data = await publicTransportApi.getPage(token.value)
|
|
|
page.value = data
|
|
|
feedbackRemark.value = data.feedbackRemark || ''
|
|
|
- feedbackQtyText.value =
|
|
|
- data.feedbackQty != null && data.feedbackQty !== undefined ? String(data.feedbackQty) : ''
|
|
|
feedbackImages.value = [...(data.feedbackImages || [])]
|
|
|
+ editableProducts.value = (data.products || []).map((prod) => {
|
|
|
+ const brandQtyRows = mapBrandQtyRows(prod)
|
|
|
+ return {
|
|
|
+ ...prod,
|
|
|
+ brandOptions: prod.brandOptions || [],
|
|
|
+ brandQtyRows,
|
|
|
+ actualQtyText: prod.brandMode
|
|
|
+ ? String(Number(brandQtySum(brandQtyRows).toFixed(3)))
|
|
|
+ : prod.actualQty != null
|
|
|
+ ? String(prod.actualQty)
|
|
|
+ : ''
|
|
|
+ }
|
|
|
+ })
|
|
|
syncUploaderFromImages()
|
|
|
} catch (e: any) {
|
|
|
errorMessage.value = e?.message || '链接无效或已失效'
|
|
|
@@ -290,22 +415,35 @@
|
|
|
}
|
|
|
|
|
|
async function handleSave() {
|
|
|
- const qtyRaw = feedbackQtyText.value.trim()
|
|
|
- let feedbackQty: number | null = null
|
|
|
- if (qtyRaw !== '') {
|
|
|
- const parsed = Number(qtyRaw)
|
|
|
- if (!Number.isFinite(parsed) || parsed < 0) {
|
|
|
- showFailToast('请输入有效的回传数量')
|
|
|
- return
|
|
|
- }
|
|
|
- feedbackQty = parsed
|
|
|
+ const brandErr = validateBrandedProducts()
|
|
|
+ if (brandErr) {
|
|
|
+ showFailToast(brandErr)
|
|
|
+ return
|
|
|
}
|
|
|
+
|
|
|
+ const products = editableProducts.value.map((item) => {
|
|
|
+ const actualQty = Number(item.actualQtyText || 0)
|
|
|
+ return {
|
|
|
+ planProductId: item.planProductId,
|
|
|
+ actualQty,
|
|
|
+ brandQtys: item.brandMode
|
|
|
+ ? (item.brandQtyRows || [])
|
|
|
+ .filter((row) => Number(row.qty) > 0)
|
|
|
+ .map((row) => ({
|
|
|
+ brandId: row.brandId,
|
|
|
+ brandName: row.brandName,
|
|
|
+ qty: Number(row.qty)
|
|
|
+ }))
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
saving.value = true
|
|
|
try {
|
|
|
await publicTransportApi.saveFeedback(token.value, {
|
|
|
feedbackRemark: feedbackRemark.value,
|
|
|
feedbackImages: feedbackImages.value,
|
|
|
- feedbackQty
|
|
|
+ products
|
|
|
})
|
|
|
showSuccessToast('保存成功')
|
|
|
await loadPage()
|
|
|
@@ -388,68 +526,152 @@
|
|
|
display: none;
|
|
|
}
|
|
|
|
|
|
- .product-cell :deep(.van-cell__title) {
|
|
|
- color: #1e293b;
|
|
|
- font-weight: 600;
|
|
|
- line-height: 1.35;
|
|
|
- white-space: normal;
|
|
|
- }
|
|
|
-
|
|
|
- .product-cell :deep(.van-cell__value) {
|
|
|
- display: none;
|
|
|
- }
|
|
|
-
|
|
|
- .actual-box {
|
|
|
- margin: 4px 12px 12px;
|
|
|
- padding: 12px 14px;
|
|
|
+ .product-card {
|
|
|
+ margin: 10px 12px;
|
|
|
+ padding: 14px;
|
|
|
border-radius: 12px;
|
|
|
background: linear-gradient(180deg, #f0fdf4 0%, #fff 48%);
|
|
|
border: 1px solid #d1fae5;
|
|
|
}
|
|
|
|
|
|
- .actual-box__label {
|
|
|
+ .product-card__head {
|
|
|
display: flex;
|
|
|
- align-items: baseline;
|
|
|
+ align-items: flex-start;
|
|
|
justify-content: space-between;
|
|
|
gap: 8px;
|
|
|
- margin-bottom: 8px;
|
|
|
+ margin-bottom: 10px;
|
|
|
}
|
|
|
|
|
|
- .actual-box__label strong {
|
|
|
- font-size: 14px;
|
|
|
+ .product-card__name {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 700;
|
|
|
color: #0f172a;
|
|
|
+ line-height: 1.35;
|
|
|
}
|
|
|
|
|
|
- .actual-box__label span {
|
|
|
+ .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;
|
|
|
- padding: 4px 10px;
|
|
|
- border-radius: 10px;
|
|
|
- background: #fff;
|
|
|
- border: 1px solid #a7f3d0;
|
|
|
}
|
|
|
|
|
|
.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__field :deep(.van-field__control) {
|
|
|
- font-size: 20px;
|
|
|
+ .actual-box__unit {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 14px;
|
|
|
font-weight: 700;
|
|
|
color: #059669;
|
|
|
+ min-width: 28px;
|
|
|
}
|
|
|
|
|
|
- .actual-box__unit {
|
|
|
- flex-shrink: 0;
|
|
|
+ .total-box {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin: 0 12px 12px;
|
|
|
+ padding: 10px 14px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #f8fafc;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .total-box__label {
|
|
|
font-size: 13px;
|
|
|
font-weight: 600;
|
|
|
color: #64748b;
|
|
|
}
|
|
|
+
|
|
|
+ .total-box__value {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #059669;
|
|
|
+ }
|
|
|
+
|
|
|
+ .page-footer {
|
|
|
+ padding: 16px;
|
|
|
+ }
|
|
|
</style>
|