feat(purchase): 采购申请弹窗加大 + 每行独立备注/照片 + 备注一键同步
- 弹窗加大到 1180px、表格 max-height 400 减少滚动 - 明细表新增「备注」列和「照片」列(每行独立上传,最多3张) - 新增「备注同步所有」按钮:把第一行备注复制到所有行 - 提交时行备注优先、行照片优先,无则回退公共备注/公共图片 - 图片校验改为逐行(行内或公共至少其一)
This commit is contained in:
@ -71,7 +71,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ========== 新建/编辑弹窗 ========== -->
|
<!-- ========== 新建/编辑弹窗 ========== -->
|
||||||
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="900px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="1180px" top="4vh" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false" class="purchase-dialog">
|
||||||
<el-form ref="formRef" :model="form" label-width="100px">
|
<el-form ref="formRef" :model="form" label-width="100px">
|
||||||
|
|
||||||
<!-- ── 物料明细表(动态多行) ── -->
|
<!-- ── 物料明细表(动态多行) ── -->
|
||||||
@ -79,11 +79,14 @@
|
|||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span>物料明细({{ formItems.length }} 项)</span>
|
<span>物料明细({{ formItems.length }} 项)</span>
|
||||||
<el-button type="primary" plain size="small" :icon="Plus" style="margin-left: 12px;" @click="addFormItem">添加一行</el-button>
|
<el-button type="primary" plain size="small" :icon="Plus" style="margin-left: 12px;" @click="addFormItem">添加一行</el-button>
|
||||||
|
<el-tooltip content="把第一行备注复制到所有行" placement="top">
|
||||||
|
<el-button type="warning" plain size="small" :icon="Copy" style="margin-left: 8px;" @click="syncRemarkToAll">备注同步所有</el-button>
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="formItems" border size="small" style="width: 100%;">
|
<el-table :data="formItems" border size="small" style="width: 100%;" :max-height="400">
|
||||||
<el-table-column type="index" label="#" width="40" align="center" />
|
<el-table-column type="index" label="#" width="38" align="center" />
|
||||||
<el-table-column label="采购物品" min-width="200">
|
<el-table-column label="采购物品" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-select
|
<el-select
|
||||||
:model-value="row.materialBaseId" filterable remote reserve-keyword="true" clearable
|
:model-value="row.materialBaseId" filterable remote reserve-keyword="true" clearable
|
||||||
@ -100,32 +103,32 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="规格型号" width="130">
|
<el-table-column label="规格型号" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.spec_model" placeholder="自动填充" clearable />
|
<el-input v-model="row.spec_model" placeholder="自动填充" clearable />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="数量" width="90">
|
<el-table-column label="数量" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number v-model="row.quantity" :min="1" :controls="false" style="width: 100%;" @change="() => onRowUnitPriceChange(row)" />
|
<el-input-number v-model="row.quantity" :min="1" :controls="false" style="width: 100%;" @change="() => onRowUnitPriceChange(row)" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="采购日期" width="130">
|
<el-table-column label="采购日期" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-date-picker v-model="row.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
|
<el-date-picker v-model="row.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="含税单价" width="100">
|
<el-table-column label="含税单价" width="95">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number v-model="row.unit_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowUnitPriceChange(row)" />
|
<el-input-number v-model="row.unit_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowUnitPriceChange(row)" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="含税总价" width="100">
|
<el-table-column label="含税总价" width="95">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number v-model="row.total_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowTotalPriceChange(row)" />
|
<el-input-number v-model="row.total_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowTotalPriceChange(row)" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="税率" width="80">
|
<el-table-column label="税率" width="75">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-select v-model="row.tax_rate" style="width: 100%;" @change="() => onRowTaxRateChange(row)">
|
<el-select v-model="row.tax_rate" style="width: 100%;" @change="() => onRowTaxRateChange(row)">
|
||||||
<el-option label="0%" :value="0" />
|
<el-option label="0%" :value="0" />
|
||||||
@ -135,7 +138,27 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="60" align="center">
|
<el-table-column label="备注" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.remark" type="textarea" :rows="1" placeholder="行备注" resize="none" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="照片" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-upload
|
||||||
|
:http-request="(opt: any) => customUploadRow(opt, row)"
|
||||||
|
:on-remove="(f: any) => handleRemoveRowImage(f, row)"
|
||||||
|
:before-upload="beforeAvatarUpload"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
list-type="picture-card"
|
||||||
|
:file-list="(row.fileList || [])"
|
||||||
|
:limit="3"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="55" align="center">
|
||||||
<template #default="{ $index }">
|
<template #default="{ $index }">
|
||||||
<el-button type="danger" link :icon="Delete" @click="removeFormItem($index)" :disabled="formItems.length <= 1" />
|
<el-button type="danger" link :icon="Delete" @click="removeFormItem($index)" :disabled="formItems.length <= 1" />
|
||||||
</template>
|
</template>
|
||||||
@ -172,7 +195,8 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-form-item label="图片凭证" required id="upload-purchase-images">
|
<el-form-item label="公共图片" id="upload-purchase-images">
|
||||||
|
<span style="font-size: 12px; color: #909399; margin-right: 8px;">未上传行内照片时使用此公共凭证</span>
|
||||||
<el-upload
|
<el-upload
|
||||||
v-model:file-list="fileList"
|
v-model:file-list="fileList"
|
||||||
:http-request="customUpload"
|
:http-request="customUpload"
|
||||||
@ -264,7 +288,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { Refresh, Plus, Delete } from '@element-plus/icons-vue'
|
import { Refresh, Plus, Delete, Copy } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { searchMaterialPurchase } from '@/api/purchase'
|
import { searchMaterialPurchase } from '@/api/purchase'
|
||||||
@ -347,6 +371,9 @@ interface FormItemRow {
|
|||||||
total_price: number | undefined
|
total_price: number | undefined
|
||||||
tax_rate: number
|
tax_rate: number
|
||||||
totalPriceManuallyEdited: boolean
|
totalPriceManuallyEdited: boolean
|
||||||
|
remark: string
|
||||||
|
images: string[]
|
||||||
|
fileList: any[]
|
||||||
}
|
}
|
||||||
const formItems = ref<FormItemRow[]>([])
|
const formItems = ref<FormItemRow[]>([])
|
||||||
|
|
||||||
@ -361,7 +388,10 @@ const createEmptyFormItem = (): FormItemRow => ({
|
|||||||
unit_price: undefined,
|
unit_price: undefined,
|
||||||
total_price: undefined,
|
total_price: undefined,
|
||||||
tax_rate: 0,
|
tax_rate: 0,
|
||||||
totalPriceManuallyEdited: false
|
totalPriceManuallyEdited: false,
|
||||||
|
remark: '',
|
||||||
|
images: [],
|
||||||
|
fileList: []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加一行
|
// 添加一行
|
||||||
@ -619,6 +649,54 @@ const handleRemoveImage = async (uploadFile: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 行内照片上传
|
||||||
|
const customUploadRow = async (options: any, row: any) => {
|
||||||
|
const { file, onSuccess, onError } = options
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
isUploading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await uploadFile(formData)
|
||||||
|
if (res.code === 200) {
|
||||||
|
const newUrl = res.data.url
|
||||||
|
row.images = row.images || []
|
||||||
|
row.images.push(newUrl)
|
||||||
|
row.fileList = row.fileList || []
|
||||||
|
row.fileList.push({ name: file.name || newUrl.split('/').pop(), url: getImageUrl(newUrl) })
|
||||||
|
onSuccess({ url: getImageUrl(newUrl) })
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '上传失败')
|
||||||
|
onError(new Error(res.msg))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
onError(e)
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 行内照片删除
|
||||||
|
const handleRemoveRowImage = async (uploadFile: any, row: any) => {
|
||||||
|
const urlToRemove = (row.images || []).find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
|
||||||
|
row.images = (row.images || []).filter(u => u !== urlToRemove)
|
||||||
|
row.fileList = (row.fileList || []).filter(f => f.url !== uploadFile.url)
|
||||||
|
const filename = urlToRemove.split('/').pop()
|
||||||
|
if (filename && !urlToRemove.startsWith('http')) {
|
||||||
|
await deleteFile(filename).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 备注一键同步所有行:取第一行的备注复制到所有行
|
||||||
|
const syncRemarkToAll = () => {
|
||||||
|
if (formItems.value.length === 0) return
|
||||||
|
const source = formItems.value[0].remark || ''
|
||||||
|
formItems.value.forEach((row, idx) => {
|
||||||
|
if (idx > 0) row.remark = source
|
||||||
|
})
|
||||||
|
ElMessage.success('已将第一行备注同步到所有行')
|
||||||
|
}
|
||||||
|
|
||||||
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
|
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
|
||||||
const handlePasteLink = (link: string, field: string) => {
|
const handlePasteLink = (link: string, field: string) => {
|
||||||
// 采购单没有独立的外链输入框,暂不支持网页图片链接自动填入
|
// 采购单没有独立的外链输入框,暂不支持网页图片链接自动填入
|
||||||
@ -629,7 +707,6 @@ usePasteUpload(customUpload, 'images', '#upload-purchase-images', handlePasteLin
|
|||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
// 1. 公共信息校验
|
// 1. 公共信息校验
|
||||||
if (!form.value.approver_id) { ElMessage.warning('请选择审批人'); return }
|
if (!form.value.approver_id) { ElMessage.warning('请选择审批人'); return }
|
||||||
if (!form.value.images || form.value.images.length === 0) { ElMessage.warning('请上传至少一张图片'); return }
|
|
||||||
|
|
||||||
// 2. 逐行校验
|
// 2. 逐行校验
|
||||||
const validItems = formItems.value.filter(i => i.name.trim())
|
const validItems = formItems.value.filter(i => i.name.trim())
|
||||||
@ -642,6 +719,13 @@ const submitForm = async () => {
|
|||||||
if (!row.name.trim()) { ElMessage.warning(`第 ${idx + 1} 行请选择采购物品`); return }
|
if (!row.name.trim()) { ElMessage.warning(`第 ${idx + 1} 行请选择采购物品`); return }
|
||||||
if (!row.purchase_date) { ElMessage.warning(`第 ${idx + 1} 行请选择采购日期`); return }
|
if (!row.purchase_date) { ElMessage.warning(`第 ${idx + 1} 行请选择采购日期`); return }
|
||||||
if (!row.quantity || row.quantity <= 0) { ElMessage.warning(`第 ${idx + 1} 行采购数量必须大于 0`); return }
|
if (!row.quantity || row.quantity <= 0) { ElMessage.warning(`第 ${idx + 1} 行采购数量必须大于 0`); return }
|
||||||
|
// 图片校验:每行必须有行内照片或公共照片
|
||||||
|
const hasRowImg = (row.images && row.images.length > 0)
|
||||||
|
const hasCommonImg = (form.value.images && form.value.images.length > 0)
|
||||||
|
if (!hasRowImg && !hasCommonImg) {
|
||||||
|
ElMessage.warning(`第 ${idx + 1} 行请上传照片(或使用公共图片凭证)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 逐行提交(每行携带公共信息)
|
// 3. 逐行提交(每行携带公共信息)
|
||||||
@ -659,8 +743,10 @@ const submitForm = async () => {
|
|||||||
tax_rate: row.tax_rate,
|
tax_rate: row.tax_rate,
|
||||||
approver_id: form.value.approver_id,
|
approver_id: form.value.approver_id,
|
||||||
supplier_link: form.value.supplier_link,
|
supplier_link: form.value.supplier_link,
|
||||||
remark: form.value.remark,
|
// 行备注优先;若空则用公共备注
|
||||||
images: form.value.images
|
remark: row.remark || form.value.remark,
|
||||||
|
// 行照片优先;若空则用公共图片凭证
|
||||||
|
images: (row.images && row.images.length > 0) ? row.images : form.value.images
|
||||||
}
|
}
|
||||||
await createPurchase(payload)
|
await createPurchase(payload)
|
||||||
successCount++
|
successCount++
|
||||||
|
|||||||
Reference in New Issue
Block a user