Files
KCGL/inventory-web/src/views/purchase/index.vue
yueli b68dfbf23d feat(purchase): 商家链接改为每行独立
- 明细表新增「商家链接」列,每行独立输入
- 移除公共区商家链接,公共信息区只保留审批人
- 提交时每行携带自己的 supplier_link
2026-08-31 13:35:34 +08:00

925 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
<el-button type="success" :icon="Plus" @click="openCreateDialog">
新建采购申请
</el-button>
</div>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="list" border stripe style="margin-top: 16px;" row-key="id">
<el-table-column prop="request_no" label="申请单号" width="180" />
<el-table-column prop="name" label="采购物品" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="80" align="center" />
<el-table-column prop="purchase_date" label="采购日期" width="110" />
<el-table-column v-if="userStore.hasPermission('inbound_purchase:unit_price')" label="含税单价" width="110" align="right">
<template #default="{ row }">
{{ row.unit_price ? '¥' + Number(row.unit_price).toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('inbound_purchase:total_price')" label="含税总价" width="120" align="right">
<template #default="{ row }">
{{ row.total_price ? '¥' + Number(row.total_price).toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('inbound_purchase:tax_rate')" prop="tax_rate" label="税率" width="80" align="center">
<template #default="{ row }">
{{ row.tax_rate != null ? row.tax_rate + '%' : '-' }}
</template>
</el-table-column>
<el-table-column prop="requester_name" label="申请人" width="100" />
<el-table-column prop="approver_name" label="审批人" width="100" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetailDialog(row)">详情</el-button>
<template v-if="row.status === 0 && canApprove">
<el-button type="success" link size="small" @click="handleApprove(row)">通过</el-button>
<el-button type="danger" link size="small" @click="openRejectDialog(row)">驳回</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="1350px" 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">
<!-- 物料明细表动态多行 -->
<div class="form-section">
<div class="section-title">
<span>物料明细{{ formItems.length }} </span>
<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="CopyDocument" style="margin-left: 8px;" @click="syncRemarkToAll">备注同步所有</el-button>
</el-tooltip>
<el-tooltip content="把第一行照片复制到所有行" placement="top">
<el-button type="success" plain size="small" :icon="Picture" style="margin-left: 8px;" @click="syncImagesToAll">照片同步所有</el-button>
</el-tooltip>
</div>
<el-table :data="formItems" border size="small" style="width: 100%;" :max-height="400">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="采购物品" min-width="200">
<template #default="{ row }">
<el-select
:model-value="row.materialBaseId" filterable remote reserve-keyword="true" clearable
placeholder="输入名称或规格搜索..." :remote-method="(q: string) => handleSearchMaterialDebounced(q, row)"
:loading="searchLoading" style="width: 100%" @change="(id: any) => onMaterialSelected(id, row)"
default-first-option="true" popper-class="long-dropdown"
>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px;">{{ item.spec_model || '-' }}</span>
</div>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="规格型号" min-width="130">
<template #default="{ row }">
<el-input v-model="row.spec_model" placeholder="自动填充" clearable />
</template>
</el-table-column>
<el-table-column label="数量" width="90">
<template #default="{ row }">
<el-input-number v-model="row.quantity" :min="1" :controls="false" style="width: 100%;" @change="() => onRowUnitPriceChange(row)" />
</template>
</el-table-column>
<el-table-column label="采购日期" width="130">
<template #default="{ row }">
<el-date-picker v-model="row.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</template>
</el-table-column>
<el-table-column label="含税单价" width="100">
<template #default="{ row }">
<el-input-number v-model="row.unit_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowUnitPriceChange(row)" />
</template>
</el-table-column>
<el-table-column label="含税总价" width="100">
<template #default="{ row }">
<el-input-number v-model="row.total_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowTotalPriceChange(row)" />
</template>
</el-table-column>
<el-table-column label="税率" width="100">
<template #default="{ row }">
<el-select v-model="row.tax_rate" style="width: 100%;" @change="() => onRowTaxRateChange(row)">
<el-option label="0%" :value="0" />
<el-option label="1%" :value="1" />
<el-option label="3%" :value="3" />
<el-option label="5%" :value="5" />
<el-option label="6%" :value="6" />
<el-option label="9%" :value="9" />
<el-option label="13%" :value="13" />
</el-select>
</template>
</el-table-column>
<el-table-column label="商家链接" min-width="160">
<template #default="{ row }">
<el-input v-model="row.supplier_link" placeholder="https://" clearable />
</template>
</el-table-column>
<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 width="90" align="center">
<!-- 表头加红星照片必填 -->
<template #header>
<span style="color: #F56C6C; margin-right: 3px;">*</span>照片
</template>
<template #default="{ row }">
<el-upload
class="table-mini-uploader"
: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="60" align="center">
<template #default="{ $index }">
<el-button type="danger" link :icon="Delete" @click="removeFormItem($index)" :disabled="formItems.length <= 1" />
</template>
</el-table-column>
</el-table>
</div>
<!-- ── 公共信息:审批人 ── -->
<div class="form-section">
<div class="section-title"><span>公共信息</span></div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="审批人" required>
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width: 100%;" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- ── 公共备注 ── -->
<div class="form-section">
<div class="section-title"><span>公共备注</span></div>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="公共备注未填写行备注时使用" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
{{ submitLoading ? '提交中...' : `确认提交 (${formItems.length} 项)` }}
</el-button>
</template>
</el-dialog>
<!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-descriptions :column="2" border>
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detail.status)" size="small">{{ detail.status_text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="采购物品">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ detail.spec_model || '-' }}</el-descriptions-item>
<el-descriptions-item label="采购数量">{{ detail.quantity }}</el-descriptions-item>
<el-descriptions-item label="采购日期">{{ detail.purchase_date }}</el-descriptions-item>
<el-descriptions-item v-if="userStore.hasPermission('inbound_purchase:unit_price')" label="单价">{{ detail.unit_price || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="userStore.hasPermission('inbound_purchase:total_price')" label="总价">{{ detail.total_price || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ detail.requester_name }}</el-descriptions-item>
<el-descriptions-item label="审批人">{{ detail.approver_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审批时间">{{ detail.approved_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="商家链接">
<a v-if="detail.supplier_link" :href="detail.supplier_link" target="_blank" style="color: #409EFF; word-break: break-all;">
{{ detail.supplier_link }}
</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</el-descriptions-item>
<el-descriptions-item label="驳回原因" :span="2" v-if="detail.reject_reason">
<span style="color: #F56C6C;">{{ detail.reject_reason }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 图片展示 -->
<div v-if="detail.images && detail.images.length > 0" style="margin-top: 16px;">
<div style="font-weight: bold; margin-bottom: 8px;">图片</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<el-image
v-for="(img, idx) in detail.images"
:key="idx"
:src="getImageUrl(img)"
:preview-src-list="detail.images.map(u => getImageUrl(u))"
fit="cover"
style="width: 100px; height: 100px; border-radius: 4px; cursor: pointer;"
/>
</div>
</div>
</el-dialog>
<!-- ========== 驳回原因弹窗 ========== -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请填写驳回原因必填" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Refresh, Plus, Delete, CopyDocument, Picture } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { searchMaterialPurchase } from '@/api/purchase'
import {
getPurchaseList, createPurchase, getPurchaseDetail,
approvePurchase, getPurchaseApprovers, autoFillPurchase
} from '@/api/purchase'
import { uploadFile, deleteFile } from '@/api/common/upload'
import type { FormInstance } from 'element-plus'
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const submitLoading = ref(false)
const rejectLoading = ref(false)
// 驳回
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
// 详情
const detailDialogVisible = ref(false)
const detail = ref<any>({})
// 创建弹窗
const formDialogVisible = ref(false)
const dialogTitle = ref('新建采购申请')
const formRef = ref<FormInstance>()
const isUploading = ref(false)
// 审批人
const approvers = ref<any[]>([])
// 物料搜索
const materialOptions = ref<any[]>([])
const searchLoading = ref(false)
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
const loadingMore = ref(false)
// v-loadmore 指令:监听 el-select 下拉滚动,滚动到底部时触发回调
const vLoadmore = {
mounted(el: any, binding: any) {
const dropdown = el.querySelector?.('.el-select-dropdown__wrap')
if (!dropdown) return
dropdown.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 2
if (condition) binding.value()
})
}
}
// 表单(公共字段)
const form = ref({
approver_id: undefined as number | undefined,
remark: ''
})
// ★ 动态明细行
interface FormItemRow {
rowKey: number
materialBaseId: number | null
name: string
spec_model: string
quantity: number
purchase_date: string
unit_price: number | undefined
total_price: number | undefined
tax_rate: number
totalPriceManuallyEdited: boolean
supplier_link: string
remark: string
images: string[]
fileList: any[]
}
const formItems = ref<FormItemRow[]>([])
// 生成一行空的采购明细
const createEmptyFormItem = (): FormItemRow => ({
rowKey: Date.now() + Math.random(),
materialBaseId: null,
name: '',
spec_model: '',
quantity: 1,
purchase_date: new Date().toISOString().split('T')[0],
unit_price: undefined,
total_price: undefined,
tax_rate: 0,
totalPriceManuallyEdited: false,
supplier_link: '',
remark: '',
images: [],
fileList: []
})
// 添加一行
const addFormItem = () => {
formItems.value.push(createEmptyFormItem())
}
// 删除一行
const removeFormItem = (index: number) => {
if (formItems.value.length <= 1) {
ElMessage.warning('至少保留一行')
return
}
formItems.value.splice(index, 1)
}
// --- 计算属性 ---
// 审批按钮仅对拥有操作权限的用户可见(菜单权限不等于审批权限)
const canApprove = computed(() => {
return userStore.role === 'SUPER_ADMIN'
|| userStore.hasPermission('inbound_purchase:operation')
})
// --- 工具函数 ---
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
}
return map[status] ?? 'info'
}
const getImageUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http')) return url
if (url.startsWith('/api/v1/common/files/')) return url
return `/api/v1/common/files/${url}`
}
const formatDate = (d: string) => d ? d.split(' ')[0] : ''
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = { page: page.value, limit: pageSize.value }
if (filterStatus.value !== '') params.status = filterStatus.value
const res: any = await getPurchaseList(params)
list.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载失败')
} finally {
loading.value = false
}
}
const fetchApprovers = async () => {
try {
const res: any = await getPurchaseApprovers()
approvers.value = res.data || []
} catch (e) {
console.error(e)
}
}
// --- 筛选 & 分页 ---
const handleStatusChange = () => {
page.value = 1
fetchData()
}
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
// --- 新建 ---
const openCreateDialog = (prefill?: { materialId?: number; name?: string; spec?: string; unit?: string }) => {
dialogTitle.value = '新建采购申请'
form.value = {
approver_id: undefined,
remark: ''
}
// ★ 初始化明细行:若有预填物料则第一行带入,否则给一行空行
const firstRow = createEmptyFormItem()
if (prefill?.materialId) {
firstRow.materialBaseId = prefill.materialId
firstRow.name = prefill.name || ''
firstRow.spec_model = prefill.spec || ''
materialOptions.value = [{
id: prefill.materialId,
name: prefill.name || '',
spec_model: prefill.spec || ''
}]
} else {
materialOptions.value = []
}
formItems.value = [firstRow]
formDialogVisible.value = true
// 异步加载默认审批人(取当前用户上一次采购申请的审批人)
fetchDefaultApprover()
}
// 获取当前用户上一次采购申请的审批人作为默认值
const fetchDefaultApprover = async () => {
try {
const res: any = await getPurchaseList({ page: 1, limit: 1, status: undefined })
const items = res?.data?.items || []
if (items.length > 0 && items[0].approver_id) {
form.value.approver_id = items[0].approver_id
}
} catch (e) {
// 静默失败,用户可手动选择
}
}
// --- 物料搜索(分页) ---
let searchTimer: any = null
const handleSearchMaterialDebounced = (query: string, _row?: any) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => handleSearchMaterial(query), 300)
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchKeyword.value = safeQuery
searchPage.value = 1
materialOptions.value = []
hasNextPage.value = true
try {
const res: any = await searchMaterialPurchase(safeQuery, 1)
materialOptions.value = res.data || []
hasNextPage.value = res.has_next !== false
} finally {
searchLoading.value = false
}
}
const handleLoadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialPurchase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
materialOptions.value = [...materialOptions.value, ...res.data]
hasNextPage.value = res.has_next !== false
} else {
hasNextPage.value = false
}
} catch {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
}
const onMaterialDropdownVisibleChange = (visible: boolean) => {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const onMaterialSelected = (id: number | null, row: any) => {
if (!id) {
row.materialBaseId = null
return
}
const item = materialOptions.value.find(i => i.id === id)
if (item) {
row.name = item.name || item.material_name || ''
row.spec_model = item.spec_model || item.spec || ''
row.materialBaseId = id
}
}
// --- 行内价格半联动逻辑 ---
const onRowUnitPriceChange = (row: any) => {
// 修改单价时:如果总价未被手动修改,自动重算总价 = 单价×数量
if (row.unit_price !== undefined && row.unit_price > 0 && row.quantity > 0 && !row.totalPriceManuallyEdited) {
row.total_price = +(row.unit_price * row.quantity).toFixed(2)
}
}
const onRowTotalPriceChange = (row: any) => {
row.totalPriceManuallyEdited = true
}
const onRowTaxRateChange = (row: any) => {
// 税率变化时:重算不含税总价 = 单价×数量
if (row.unit_price && row.quantity) {
row.total_price = Number((row.unit_price * row.quantity).toFixed(2))
}
}
// --- 上传 ---
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'].includes(rawFile.type)
if (!isTypeValid) {
ElMessage.error('仅支持 JPG/PNG/GIF/WEBP 图片')
return false
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('图片不能超过 10MB')
return false
}
return true
}
// ★ 行内照片上传
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 countImageRefs = (urlToRemove: string): number => {
let count = 0
formItems.value.forEach(r => {
if ((r.images || []).includes(urlToRemove)) count++
})
return count
}
// ★ 行内照片删除(带确认 + 跨行引用保护)
const handleRemoveRowImage = async (uploadFile: any, row: any) => {
const urlToRemove = (row.images || []).find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
const filename = urlToRemove.split('/').pop()
const isInternal = filename && !urlToRemove.startsWith('http')
// 检查该照片是否也被其他行引用(同步照片场景)
const refCount = countImageRefs(urlToRemove)
const sharedByOthers = refCount > 1 // 包含当前行
if (sharedByOthers) {
// ★ 同步照片:询问是否全部删除
const otherCount = refCount - 1
try {
const action = await ElMessageBox.confirm(
`此照片当前被 ${refCount} 行使用(含本行)。\n\n` +
`• 【全部删除】将移除所有行中的该照片,并删除服务器文件\n` +
`• 【仅移出此行】只从当前行移除引用,其他行保留(不删服务器文件)`,
'删除照片',
{
confirmButtonText: '全部删除',
cancelButtonText: '仅移出此行',
distinguishCancelAndClose: true,
type: 'warning'
}
)
// 用户点【全部删除】
formItems.value.forEach(r => {
r.images = (r.images || []).filter(u => u !== urlToRemove)
r.fileList = (r.fileList || []).filter(f => f.url !== uploadFile.url && f.url !== urlToRemove)
})
if (isInternal) await deleteFile(filename).catch(() => {})
ElMessage.success(`已从 ${refCount} 行删除该照片`)
} catch (action) {
if (action === 'cancel') {
// 用户点【仅移出此行】
row.images = (row.images || []).filter(u => u !== urlToRemove)
row.fileList = (row.fileList || []).filter(f => f.url !== uploadFile.url && f.url !== urlToRemove)
ElMessage.success('已从当前行移除该照片,其他行保留')
}
// close 或直接关闭 → 不操作
}
} else {
// 普通删除:仅当前行引用,确认后删除
try {
await ElMessageBox.confirm(
`确定删除这张照片吗?删除后不可恢复。`,
'删除照片',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
row.images = (row.images || []).filter(u => u !== urlToRemove)
row.fileList = (row.fileList || []).filter(f => f.url !== uploadFile.url && f.url !== urlToRemove)
if (isInternal) await deleteFile(filename).catch(() => {})
ElMessage.success('照片已删除')
} catch (e) {
// 用户取消,不操作
}
}
}
// ★ 备注一键同步所有行:取第一行的备注复制到所有行
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('已将第一行备注同步到所有行')
}
// ★ 照片一键同步所有行:取第一行的照片深拷贝到所有行
const syncImagesToAll = () => {
if (formItems.value.length <= 1) {
ElMessage.warning('当前只有一行,无需同步')
return
}
const firstRow = formItems.value[0]
const firstFiles = firstRow.fileList || []
const firstImages = firstRow.images || []
if (firstImages.length === 0) {
ElMessage.warning('第一行没有上传照片,无法同步')
return
}
// 深拷贝,避免某行删除照片时影响其他行
const clonedFiles = JSON.parse(JSON.stringify(firstFiles))
const clonedImages = JSON.parse(JSON.stringify(firstImages))
formItems.value.forEach((row, idx) => {
if (idx > 0) {
row.fileList = JSON.parse(JSON.stringify(clonedFiles))
row.images = JSON.parse(JSON.stringify(clonedImages))
}
})
ElMessage.success('照片已同步至所有明细行')
}
// --- 提交(循环逐行调用单条接口) ---
const submitForm = async () => {
// 1. 公共信息校验
if (!form.value.approver_id) { ElMessage.warning('请选择审批人'); return }
// 2. 逐行校验
const validItems = formItems.value.filter(i => i.name.trim())
if (validItems.length === 0) { ElMessage.warning('请至少填写一行采购物品'); return }
if (validItems.length < formItems.value.length) {
ElMessage.warning('存在未填写物品名称的行,请填写或删除空行')
return
}
for (const [idx, row] of validItems.entries()) {
if (!row.name.trim()) { 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.images || row.images.length === 0) {
ElMessage.warning(`请为第 ${idx + 1} 行【${row.name || '未命名物品'}】上传至少一张照片凭证!`)
return
}
}
// 3. 逐行提交(每行携带公共信息)
submitLoading.value = true
let successCount = 0
try {
for (const row of validItems) {
const payload: any = {
name: row.name,
spec_model: row.spec_model,
quantity: row.quantity,
purchase_date: row.purchase_date,
unit_price: row.unit_price,
total_price: row.total_price,
tax_rate: row.tax_rate,
approver_id: form.value.approver_id,
// ★ 商家链接每行独立
supplier_link: row.supplier_link || '',
// 行备注优先;若空则用公共备注
remark: row.remark || form.value.remark,
// ★ 每行必填照片
images: row.images || []
}
await createPurchase(payload)
successCount++
}
ElMessage.success(`成功提交 ${successCount} 条采购申请,审批人将收到邮件通知`)
formDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || `提交失败(已完成 ${successCount} 条)`)
} finally {
submitLoading.value = false
}
}
// --- 详情 ---
const openDetailDialog = async (row: any) => {
try {
const res: any = await getPurchaseDetail(row.id)
detail.value = res.data || {}
detailDialogVisible.value = true
} catch (e) {
ElMessage.error('获取详情失败')
}
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要通过采购申请「${row.request_no}」吗?`, '审批确认', {
confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info'
})
} catch { return }
try {
await approvePurchase(row.id, { action: 'approve' })
ElMessage.success('已通过')
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
rejectLoading.value = true
try {
await approvePurchase(currentRejectRow.value.id, {
action: 'reject', reject_reason: rejectReason.value
})
ElMessage.success('已驳回')
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
const route = useRoute()
onMounted(() => {
fetchData()
fetchApprovers()
// 从基础信息跳转过来的参数
const query = route.query
if (query.material_id) {
openCreateDialog({
materialId: Number(query.material_id),
name: (query.name as string) || '',
spec: (query.spec as string) || '',
unit: (query.unit as string) || ''
})
}
})
</script>
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* 表单分组 */
.form-section {
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px 16px 4px;
margin-bottom: 14px;
}
.form-section .section-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.form-section .section-title span {
position: relative;
padding-left: 10px;
}
.form-section .section-title span::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
background: #409EFF;
border-radius: 2px;
}
/* ★ 表格内迷你上传框:缩小到 60x60避免撑爆表格 */
:deep(.table-mini-uploader) .el-upload--picture-card {
width: 60px;
height: 60px;
line-height: 66px;
}
:deep(.table-mini-uploader) .el-upload--picture-card .el-icon {
font-size: 20px;
}
:deep(.table-mini-uploader) .el-upload-list--picture-card .el-upload-list__item {
width: 60px;
height: 60px;
}
:deep(.table-mini-uploader) .el-upload-list--picture-card .el-upload-list__item img {
width: 60px;
height: 60px;
object-fit: cover;
}
:deep(.table-mini-uploader) .el-upload-list--picture-card .el-upload-list__item-actions {
font-size: 14px;
}
</style>