feat(pl): driver public feedback by product and brand qty
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0667e8d77b
commit
2a215b2e1f
4 files changed
+436
-72
No files matched your search
@@ -1,12 +1,25 @@
|
||||
import { publicPlGet, publicPlPost, publicPlUpload } from '@/utils/http/public-pl'
|
||||
|
||||
export interface BrandQtyItem {
|
||||
brandId: string
|
||||
brandName?: string
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface PublicTransportProduct {
|
||||
planProductId: string
|
||||
productName: string
|
||||
model?: string
|
||||
packageSpec?: string
|
||||
unit?: string
|
||||
quantity?: number
|
||||
remark?: string
|
||||
brandMode?: string | null
|
||||
brandId?: string | null
|
||||
brandName?: string | null
|
||||
brandOptions?: { brandId: string; brandName: string }[]
|
||||
actualQty?: number | null
|
||||
brandQtys?: BrandQtyItem[]
|
||||
}
|
||||
|
||||
export interface PublicTransportPersonnel {
|
||||
@@ -51,7 +64,11 @@ export const publicTransportApi = {
|
||||
getPage: (token: string) => publicPlGet<PublicTransportPage>(`/public/pl/transport/${token}`),
|
||||
saveFeedback: (
|
||||
token: string,
|
||||
data: { feedbackImages?: string[]; feedbackRemark?: string; feedbackQty?: number | null }
|
||||
data: {
|
||||
feedbackImages?: string[]
|
||||
feedbackRemark?: string
|
||||
products: { planProductId: string; actualQty: number; brandQtys?: BrandQtyItem[] }[]
|
||||
}
|
||||
) => publicPlPost<{ success: boolean }>(`/public/pl/transport/${token}/feedback`, data),
|
||||
uploadAttachment: (token: string, file: File) =>
|
||||
publicPlUpload<PublicUploadResult>(`/public/pl/transport/${token}/attachments`, file)
|
||||
|
||||
@@ -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;
|
||||
|
||||
.actual-box__field :deep(.van-field__control) {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
: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;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in new issue
Block a user