feat(web-admin): driver feedback dialog brand qty entry
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user