feat(web-admin): driver feedback dialog brand qty entry

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-31 05:31:22 +08:00
1 parent 2a215b2e1f
commit b7236329c2
5 files changed
+458 -69

No files matched your search

@@ -3,7 +3,7 @@
<el-dialog
v-model="innerVisible"
:title="dialogTitle"
width="640px"
width="720px"
align-center
destroy-on-close
append-to-body
@@ -14,50 +14,90 @@
@closed="handleClosed"
@opened="handleOpened"
>
<el-form
ref="formRef"
:model="formData"
label-position="top"
class="form-grid-2"
>
<el-form-item label="回传时间">
<el-date-picker
v-model="formData.feedbackTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="不填则保存为当前时间"
style="width: 100%"
clearable
/>
</el-form-item>
<el-form-item label="回传数量">
<el-input-number
v-model="formData.feedbackQty"
:min="0"
:precision="3"
placeholder="回传数量"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="回传备注" class="form-col-span-2">
<el-input
v-model="formData.feedbackRemark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="回传备注"
/>
</el-form-item>
<el-form-item label="回传附件" class="form-col-span-2">
<AttachmentUploadField
v-model="formData.feedbackImages"
folder="pl/transport/feedback"
accept="image/*,video/*,.pdf"
:limit="15"
/>
</el-form-item>
</el-form>
<div v-loading="loading" class="pl-driver-feedback-form">
<el-form
ref="formRef"
:model="formData"
label-position="top"
class="form-grid-2"
>
<el-form-item label="回传时间">
<el-date-picker
v-model="formData.feedbackTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="不填则保存为当前时间"
style="width: 100%"
clearable
/>
</el-form-item>
<el-form-item label="回传备注" class="form-col-span-2">
<el-input
v-model="formData.feedbackRemark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="回传备注"
/>
</el-form-item>
<el-form-item label="回传附件" class="form-col-span-2">
<AttachmentUploadField
v-model="formData.feedbackImages"
folder="pl/transport/feedback"
accept="image/*,video/*,.pdf"
:limit="15"
/>
</el-form-item>
</el-form>
<section class="pl-driver-feedback-products">
<h4 class="pl-driver-feedback-products__title">产品回传数量</h4>
<p v-if="!productRows.length && !loading" class="pl-driver-feedback-products__empty">
暂无产品明细
</p>
<div
v-for="(row, idx) in productRows"
:key="row.planProductId"
class="pl-driver-feedback-product"
>
<div class="pl-driver-feedback-product__head">
<span class="pl-driver-feedback-product__name">{{ row.productName || '产品' }}</span>
<span class="pl-driver-feedback-product__badge">
产品 {{ idx + 1 }}/{{ productRows.length }}
</span>
</div>
<div class="pl-driver-feedback-product__actual">
<span class="pl-driver-feedback-product__actual-label">实际数量</span>
<el-input-number
v-model="row.actualQty"
:min="0"
:precision="3"
:controls="false"
:disabled="Boolean(row.brandMode)"
class="pl-driver-feedback-product__actual-input"
@change="() => onActualQtyChange(row)"
/>
<span class="pl-driver-feedback-product__unit">{{ row.unitLabel || row.unit || '吨' }}</span>
</div>
<BrandQtyChipsField
v-if="row.brandMode"
v-model="row.brandQtyRows"
:brand-mode="brandModeForField(row)"
:required-brand-id="row.brandId"
:brand-options="row.brandOptions"
:unit="row.unitLabel || row.unit || '吨'"
variant="admin"
:actual-qty="Number(row.actualQty ?? 0)"
@update:actual-qty="(val) => syncRowActualQty(row, val)"
/>
</div>
<div v-if="productRows.length > 1" class="pl-driver-feedback-total">
<span>合计回传</span>
<strong>{{ totalFeedbackQty }}</strong>
</div>
</section>
</div>
<template #footer>
<div class="dialog-footer">
@@ -75,18 +115,43 @@
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import type { FormInstance } from 'element-plus'
import { fetchProductDetail, type ProductDetail } from '@/api/crm/product'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { planApi, type PlanDriverFeedbackUpdateBody } from '@/api/pl'
import BrandQtyChipsField, {
type BrandOption,
type BrandQtyRow
} from '@/components/business/brand-qty-chips-field/index.vue'
import {
planApi,
type Plan,
type PlanDriverFeedbackUpdateBody,
type PlanProduct
} from '@/api/pl'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData } from '@/utils/http/bx'
import { parsePlFeedbackImages } from '@/utils/pl-display'
interface DriverFeedbackProductRow {
planProductId: string
productName: string
unit: string
unitLabel: string
brandMode?: string
brandId?: string
brandName?: string
brandOptions: BrandOption[]
actualQty: number
brandQtyRows: BrandQtyRow[]
}
const props = defineProps<{
visible: boolean
planId: string
isEdit?: boolean
feedbackQty?: number | null
feedbackRemark?: string | null
feedbackImages?: string | string[] | null
feedbackTime?: string | null
feedbackQty?: number | null
}>()
const emit = defineEmits<{
@@ -94,54 +159,236 @@ const emit = defineEmits<{
success: []
}>()
const dictStore = useDictStore()
const innerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const formRef = ref<FormInstance>()
const loading = ref(false)
const submitLoading = ref(false)
const productRows = ref<DriverFeedbackProductRow[]>([])
const formData = reactive({
feedbackTime: '' as string,
feedbackQty: undefined as number | undefined,
feedbackRemark: '',
feedbackImages: ''
})
const dialogTitle = computed(() => (props.isEdit ? '编辑司机回传信息' : '补充司机回传信息'))
function resetForm() {
const totalFeedbackQty = computed(() =>
Number(
productRows.value.reduce((sum, row) => sum + Number(row.actualQty ?? 0), 0).toFixed(3)
)
)
function brandModeForField(row: DriverFeedbackProductRow): 'any' | 'required' | null {
if (row.brandMode === 'any' || row.brandMode === 'required') return row.brandMode
return null
}
function syncRowActualQty(row: DriverFeedbackProductRow, val: number) {
row.actualQty = Number(val)
}
function onActualQtyChange(row: DriverFeedbackProductRow) {
if (row.brandMode === 'required' && row.brandQtyRows.length) {
row.brandQtyRows[0].qty = Number(row.actualQty ?? 0)
}
}
function brandQtySum(rows: BrandQtyRow[]) {
return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
}
function resolveUnitLabel(unit?: string) {
const code = (unit || '').trim()
return dictStore.getLabel('crm_product_unit', code) || code || '吨'
}
function mapBrandQtyRows(
prod: PlanProduct,
brandOptions: BrandOption[],
seedActualQty: number
): BrandQtyRow[] {
if (prod.brandMode === 'required') {
const brandId = prod.brandId || brandOptions[0]?.brandId || ''
const brandName =
prod.brandName ||
brandOptions.find(o => o.brandId === brandId)?.brandName ||
'指定品牌'
return brandId ? [{ brandId, brandName, qty: seedActualQty }] : []
}
if (prod.brandMode === 'any') {
const rows = brandOptions.map(opt => ({
brandId: opt.brandId,
brandName: opt.brandName,
qty: 0
}))
if (rows.length === 1) {
rows[0].qty = seedActualQty
}
return rows
}
return []
}
async function loadBrandOptions(productId: string): Promise<BrandOption[]> {
if (!productId) return []
try {
const res = await fetchProductDetail({ id: productId })
const detail = pickBxData<ProductDetail>(res)
return (detail?.brands || [])
.filter(b => (b.status || 'on') === 'on' && b.id && b.brandName)
.map(b => ({
brandId: String(b.id),
brandName: String(b.brandName)
}))
} catch {
return []
}
}
function resolveSeedActualQty(
prod: PlanProduct,
transportFeedbackQty: number | null | undefined,
productCount: number
) {
if (prod.actualQty != null) return Number(prod.actualQty)
if (productCount === 1 && transportFeedbackQty != null) {
return Number(transportFeedbackQty)
}
return 0
}
async function buildProductRows(plan: Plan, transportFeedbackQty?: number | null) {
const products = Array.isArray(plan.products) ? plan.products : []
const brandOptionMap = new Map<string, BrandOption[]>()
await Promise.all(
[...new Set(products.filter(p => p.brandMode === 'any' && p.productId).map(p => p.productId))].map(
async productId => {
brandOptionMap.set(productId, await loadBrandOptions(productId))
}
)
)
productRows.value = products.map(prod => {
const brandOptions =
prod.brandMode === 'any'
? brandOptionMap.get(prod.productId) || []
: prod.brandId
? [{ brandId: prod.brandId, brandName: prod.brandName || '指定品牌' }]
: []
const seedActualQty = resolveSeedActualQty(prod, transportFeedbackQty, products.length)
const brandQtyRows = mapBrandQtyRows(prod, brandOptions, seedActualQty)
const actualQty = prod.brandMode
? Number(brandQtySum(brandQtyRows).toFixed(3))
: seedActualQty
return {
planProductId: prod.id,
productName: prod.productName || '',
unit: prod.unit || '吨',
unitLabel: resolveUnitLabel(prod.unit),
brandMode: prod.brandMode,
brandId: prod.brandId,
brandName: prod.brandName,
brandOptions,
actualQty,
brandQtyRows
}
})
}
function resetMetaForm() {
formData.feedbackTime = props.feedbackTime ? String(props.feedbackTime) : ''
formData.feedbackQty =
props.feedbackQty != null && props.feedbackQty !== undefined
? Number(props.feedbackQty)
: undefined
formData.feedbackRemark = props.feedbackRemark ? String(props.feedbackRemark) : ''
const images = parsePlFeedbackImages(props.feedbackImages)
formData.feedbackImages = images.length ? images.join(',') : ''
}
function handleOpened() {
resetForm()
async function handleOpened() {
if (!props.planId) return
loading.value = true
productRows.value = []
resetMetaForm()
try {
const res = await planApi.get(props.planId)
const plan = pickBxData<Plan>(res)
if (!plan?.id) {
throw new Error('运输计划数据无效')
}
await buildProductRows(plan, props.feedbackQty)
} catch (e: unknown) {
innerVisible.value = false
const message = e instanceof Error ? e.message : '加载失败'
ElMessage.error(message.includes('加载') ? message : `加载运输计划失败:${message}`)
} finally {
loading.value = false
}
}
function handleClosed() {
formRef.value?.resetFields()
productRows.value = []
}
function validateProductRows(): string | null {
if (!productRows.value.length) {
return '暂无产品明细,无法保存'
}
for (const row of productRows.value) {
if (row.actualQty == null || Number(row.actualQty) < 0) {
return `「${row.productName || '产品'}」请填写有效的实际数量`
}
if (!row.brandMode) continue
const positiveRows = (row.brandQtyRows || []).filter(r => Number(r.qty) > 0)
if (!positiveRows.length) {
return `「${row.productName || '产品'}」请至少填写一个品牌数量`
}
const total = Number(brandQtySum(row.brandQtyRows).toFixed(3))
const actual = Number(row.actualQty ?? 0)
if (Math.abs(total - actual) >= 0.0005) {
return `「${row.productName || '产品'}」品牌数量合计须等于实际数量`
}
}
return null
}
async function handleSubmit() {
if (!props.planId) return
const validationMessage = validateProductRows()
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
submitLoading.value = true
try {
const imageList = formData.feedbackImages
? formData.feedbackImages.split(',').map(item => item.trim()).filter(Boolean)
: []
const body: PlanDriverFeedbackUpdateBody = {
feedbackQty: formData.feedbackQty ?? null,
feedbackRemark: formData.feedbackRemark.trim() || null,
feedbackImages: imageList,
feedbackTime: formData.feedbackTime || null
feedbackTime: formData.feedbackTime || null,
products: productRows.value.map(row => ({
planProductId: row.planProductId,
actualQty: Number(row.actualQty ?? 0),
brandQtys: row.brandMode
? (row.brandQtyRows || [])
.filter(r => Number(r.qty) > 0)
.map(r => ({
brandId: r.brandId,
brandName: r.brandName,
qty: Number(r.qty)
}))
: undefined
}))
}
await planApi.updateDriverFeedback(props.planId, body)
ElMessage.success('司机回传信息已保存')
@@ -162,4 +409,95 @@ async function handleSubmit() {
justify-content: flex-end;
gap: 8px;
}
.pl-driver-feedback-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.pl-driver-feedback-products__title {
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
color: var(--section-bar-title-color, var(--primary-dark, #059669));
border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
line-height: 1;
}
.pl-driver-feedback-products__empty {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted);
}
.pl-driver-feedback-product {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
& + & {
margin-top: 10px;
}
}
.pl-driver-feedback-product__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.pl-driver-feedback-product__name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.pl-driver-feedback-product__badge {
font-size: 12px;
color: #64748b;
}
.pl-driver-feedback-product__actual {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
.pl-driver-feedback-product__actual-label {
font-size: 13px;
color: #64748b;
}
.pl-driver-feedback-product__actual-input {
width: 100%;
}
.pl-driver-feedback-product__unit {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.pl-driver-feedback-total {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #047857;
font-size: 13px;
strong {
font-size: 16px;
}
}
</style>
@@ -470,10 +470,10 @@
v-model:visible="driverFeedbackDialogVisible"
:plan-id="planId"
:is-edit="hasDriverFeedback"
:feedback-qty="transport?.feedbackQty"
:feedback-remark="transport?.feedbackRemark"
:feedback-images="transport?.feedbackImages"
:feedback-time="transport?.feedbackTime"
:feedback-qty="transport?.feedbackQty"
@success="handleFeedbackSaved"
/>
<PlPlanSiteFeedbackDialog