Files
KCGL/inventory-web/src/views/purchase/index.vue

567 lines
21 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 label="单价/总价" width="120" align="right">
<template #default="{ row }">
<span v-if="row.unit_price || row.total_price">
{{ row.unit_price || '-' }} / {{ row.total_price || '-' }}
</span>
<span v-else>-</span>
</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="700px" destroy-on-close :close-on-click-modal="false">
<el-form ref="formRef" :model="form" label-width="110px">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="采购物品" required>
<el-select
v-model="materialBaseId"
filterable
remote
reserve-keyword
clearable
placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
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>
<div v-if="autoFillHint" style="font-size: 12px; color: #67C23A; margin-top: 4px;">{{ autoFillHint }}</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号">
<el-input v-model="form.spec_model" placeholder="从物料列表选择后自动填充" clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购数量" required>
<el-input-number v-model="form.quantity" :min="1" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购日期" required>
<el-date-picker v-model="form.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
<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-col :span="12">
<el-form-item label="单价">
<el-input-number v-model="form.unit_price" :min="0" :precision="2" style="width: 100%;" @change="onUnitPriceChange" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总价">
<el-input-number v-model="form.total_price" :min="0" :precision="2" style="width: 100%;" @change="onTotalPriceChange" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商家链接">
<el-input v-model="form.supplier_link" placeholder="商家地址链接(选填)" clearable />
</el-form-item>
</el-col>
<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-col :span="24">
<el-form-item label="图片上传">
<el-upload
v-model:file-list="fileList"
:http-request="customUpload"
:on-remove="handleRemoveImage"
:before-upload="beforeAvatarUpload"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
list-type="picture-card"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">确认提交</el-button>
</template>
</el-dialog>
<!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close>
<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 label="单价">{{ detail.unit_price || '-' }}</el-descriptions-item>
<el-descriptions-item 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;">
{{ 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>
<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 { Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { searchMaterialBase } from '@/api/inbound/buy'
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 fileList = ref<any[]>([])
// 驳回
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 autoFillHint = ref('')
// 审批人
const approvers = ref<any[]>([])
// 物料搜索
const materialOptions = ref<any[]>([])
const searchLoading = ref(false)
const materialBaseId = ref<number | null>(null)
// 表单
const form = ref({
name: '',
spec_model: '',
quantity: 1,
purchase_date: '',
supplier_link: '',
remark: '',
unit_price: undefined as number | undefined,
total_price: undefined as number | undefined,
approver_id: undefined as number | undefined,
images: [] as string[]
})
// --- 计算属性 ---
const canApprove = computed(() => {
return userStore.role === 'SUPER_ADMIN' || userStore.role === 'SUPERVISOR'
})
// --- 工具函数 ---
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
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 = () => {
dialogTitle.value = '新建采购申请'
form.value = {
name: '', spec_model: '', quantity: 1,
purchase_date: new Date().toISOString().split('T')[0],
supplier_link: '', remark: '',
unit_price: undefined, total_price: undefined,
approver_id: undefined, images: []
}
materialBaseId.value = null
materialOptions.value = []
autoFillHint.value = ''
fileList.value = []
formDialogVisible.value = true
}
// --- 物料搜索 ---
let searchTimer: any = null
const handleSearchMaterialDebounced = (query: string) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => handleSearchMaterial(query), 300)
}
const handleSearchMaterial = async (query: string) => {
if (!query.trim()) {
materialOptions.value = []
return
}
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query, 1)
materialOptions.value = res.data || []
} finally {
searchLoading.value = false
}
}
const onMaterialSelected = (id: number | null) => {
if (!id) {
materialBaseId.value = null
return
}
const item = materialOptions.value.find(i => i.id === id)
if (item) {
form.value.name = item.name || item.material_name || ''
form.value.spec_model = item.spec_model || item.spec || ''
materialBaseId.value = id
autoFillHint.value = ''
}
}
// --- 价格自动计算(需确认)---
let priceConfirmTimer: any = null
const onUnitPriceChange = (val: number | undefined) => {
if (priceConfirmTimer) clearTimeout(priceConfirmTimer)
priceConfirmTimer = setTimeout(() => {
if (val !== undefined && val > 0 && form.value.quantity > 0 && !form.value.total_price) {
ElMessageBox.confirm(
`即将自动计算总价:${val} × ${form.value.quantity} = ${+(val * form.value.quantity).toFixed(2)},是否继续?`,
'自动计算确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(() => {
form.value.total_price = +(val * form.value.quantity).toFixed(2)
}).catch(() => {})
} else if (val !== undefined && val > 0 && form.value.quantity > 0) {
form.value.total_price = +(val * form.value.quantity).toFixed(2)
}
}, 300)
}
const onTotalPriceChange = (val: number | undefined) => {
if (priceConfirmTimer) clearTimeout(priceConfirmTimer)
priceConfirmTimer = setTimeout(() => {
if (val !== undefined && val > 0 && form.value.quantity > 0 && !form.value.unit_price) {
ElMessageBox.confirm(
`即将自动计算单价:${val} ÷ ${form.value.quantity} = ${+(val / form.value.quantity).toFixed(4)},是否继续?`,
'自动计算确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(() => {
form.value.unit_price = +(val / form.value.quantity).toFixed(4)
}).catch(() => {})
} else if (val !== undefined && val > 0 && form.value.quantity > 0) {
form.value.unit_price = +(val / form.value.quantity).toFixed(4)
}
}, 300)
}
// --- 上传 ---
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 customUpload = async (options: 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
form.value.images!.push(newUrl)
onSuccess(res)
} else {
ElMessage.error(res.msg || '上传失败')
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误')
onError(e)
} finally {
isUploading.value = false
}
}
const handleRemoveImage = async (uploadFile: any) => {
const urlToRemove = form.value.images!.find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form.value.images = form.value.images!.filter(u => u !== urlToRemove)
const filename = urlToRemove.split('/').pop()
if (filename && !urlToRemove.startsWith('http')) {
await deleteFile(filename).catch(() => {})
}
}
// --- 提交 ---
const submitForm = async () => {
if (!form.value.name.trim()) { ElMessage.warning('请选择或填写采购物品'); return }
if (!form.value.approver_id) { ElMessage.warning('请选择审批人'); return }
if (!form.value.purchase_date) { ElMessage.warning('请选择采购日期'); return }
if (!form.value.images || form.value.images.length === 0) { ElMessage.warning('请上传至少一张图片'); return }
submitLoading.value = true
try {
await createPurchase(form.value as any)
ElMessage.success('提交成功,审批人将收到邮件通知')
formDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '提交失败')
} 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
}
}
// --- 初始化 ---
onMounted(() => {
fetchData()
fetchApprovers()
})
</script>
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
</style>