material_manage_system/frontend/src/views/Materials.vue

514 lines
19 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="materials-page">
<div class="page-header">
<h2>物料主数据</h2>
<el-button type="primary" @click="showAddModal">新增物料</el-button>
</div>
<div class="search-bar">
<el-select
v-model="searchKeyword"
placeholder="输入物料编码或名称搜索"
filterable
:filter-method="materialFilter"
:no-data-text="materialSearchText.trim() ? '未找到匹配物料,请尝试其他关键词' : '请先输入物料编码或名称'"
clearable
style="width: 260px"
>
<el-option
v-for="m in getFilteredMaterials()"
:key="m.id"
:label="`${m.material_code} - ${m.name}`"
:value="m.material_code"
/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<BaseTable
:data="materialsStore.materials"
:loading="materialsStore.loading"
:current-page="materialsStore.pagination.currentPage"
:page-size="materialsStore.pagination.pageSize"
:total="materialsStore.pagination.total"
@page-change="handlePageChange"
>
<el-table-column prop="material_code" label="物料编码" />
<el-table-column label="大类类型" min-width="105">
<template #default="{ row }">
{{ getMajorCategoryDisplay(row) }}
</template>
</el-table-column>
<el-table-column label="小类类型" min-width="91">
<template #default="{ row }">
{{ getMinorCategoryDisplay(row) }}
</template>
</el-table-column>
<el-table-column prop="name" label="物料名称" />
<el-table-column prop="spec" label="规格" />
<el-table-column label="单位" width="60">
<template #default="{ row }">
{{ getUnitDisplay(row) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="240"
fixed="right"
class-name="operation-column"
label-class-name="operation-column"
>
<template #default="{ row }">
<el-button size="small" @click="showDetail(row)">详情</el-button>
<el-button v-if="authStore.isAdmin && row.status !== 'obsolete'" size="small" type="danger" @click="handleDelete(row.id)">作废物料</el-button>
<el-button v-if="authStore.isAdmin && row.status === 'obsolete'" size="small" type="success" @click="handleRestore(row.id)">撤销作废</el-button>
</template>
</el-table-column>
</BaseTable>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="handleDialogClose">
<el-form v-if="isCreating || isEditing" :model="form" label-width="100px">
<el-form-item label="物料编码" required>
<div class="material-code-field">
<el-input
v-model="form.material_code"
:readonly="!isCreating"
:placeholder="codePlaceholder"
@input="markCodeEdited"
/>
<el-button v-if="isCreating" link type="primary" @click="regenerateMaterialCode">重新生成</el-button>
</div>
</el-form-item>
<el-form-item label="名称" required>
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="form.spec" />
</el-form-item>
<el-form-item label="大类类型" required>
<el-select v-model="form.major_category_id" style="width: 100%" @change="syncMajorCategory">
<el-option v-for="category in majorCategories" :key="category.id" :label="categoryLabel(category, true)" :value="category.id" :disabled="category.status !== 'active'" />
</el-select>
</el-form-item>
<el-form-item label="小类类型" required>
<el-select v-model="form.category_id" :disabled="!form.major_category_id" style="width: 100%" @change="syncCategory">
<el-option v-for="category in minorCategories" :key="category.id" :label="minorCategoryLabel(category, form.major_category_id)" :value="category.id" :disabled="category.status !== 'active'" />
</el-select>
</el-form-item>
<el-form-item label="单位">
<el-select v-model="form.base_unit_id" style="width: 100%" @change="syncUnit">
<el-option v-for="unit in units" :key="unit.id" :label="unit.code === unit.name ? unit.code : `${unit.code} - ${unit.name}`" :value="unit.id" />
</el-select>
</el-form-item>
<el-form-item label="图片URL">
<el-input v-model="form.material_image" placeholder="图片URL" />
</el-form-item>
<el-form-item label="图片上传">
<input type="file" accept="image/*" @change="handleImageUpload" ref="imageInput" />
<img v-if="imagePreview" :src="imagePreview" style="max-width:200px;margin-top:10px;" />
</el-form-item>
</el-form>
<el-descriptions v-else-if="detailRow" :column="2" border>
<el-descriptions-item label="物料编码">{{ detailRow.material_code }}</el-descriptions-item>
<el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item>
<el-descriptions-item label="规格">{{ detailRow.spec || '-' }}</el-descriptions-item>
<el-descriptions-item label="大类类型">{{ getMajorCategoryDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="小类类型">{{ getMinorCategoryDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ getUnitDisplay(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(detailRow.created_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ formatDate(detailRow.updated_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
<el-descriptions-item label="物料图片" :span="2">
<el-image
v-if="detailRow.material_image"
:src="detailRow.material_image"
style="width: 100px; height: 100px; object-fit: contain"
:preview-src-list="[detailRow.material_image]"
fit="contain"
/>
<span v-else>-</span>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<template v-if="isCreating">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
</template>
<template v-else-if="isEditing">
<el-button @click="cancelEdit">取消编辑</el-button>
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
</template>
<template v-else>
<el-button @click="dialogVisible = false">关闭</el-button>
<el-button v-if="authStore.isAdmin" type="primary" @click="startEdit">编辑</el-button>
</template>
</template>
</el-dialog>
<DeleteConfirm
v-model="deleteDialogVisible"
title="确认作废该物料?"
content="作废后该物料不能再用于新业务;已有业务记录仍会保留。"
confirm-text="确认作废"
:on-confirm="confirmDelete"
/>
<AlertTip :message="alertMessage" :type="alertType" />
</div>
</template>
<script setup>
import { computed, ref, reactive, onMounted } from 'vue'
import { useMaterialsStore } from '../stores/materials'
import { useAuthStore } from '../stores/auth'
import BaseTable from '../components/business/BaseTable.vue'
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
import AlertTip from '../components/business/AlertTip.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createMaterialForm, toMaterialPayload } from './materialForm'
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
import { searchMaterialOptions } from '../utils/materialSearch'
import { errorText } from '../utils/errorText'
import { formatDate } from '../utils/datetime'
// 物料详情的展示沿用原先不带秒的格式
const MATERIAL_DATE_OPTIONS = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}
const materialsStore = useMaterialsStore()
const authStore = useAuthStore()
const searchKeyword = ref('')
const materialSearchText = ref('')
const dialogVisible = ref(false)
const dialogMode = ref('create')
const deleteDialogVisible = ref(false)
const saveLoading = ref(false)
const editingId = ref(null)
const deleteId = ref(null)
const detailRow = ref(null)
const alertMessage = ref('')
const alertType = ref('success')
const imagePreview = ref('')
const imageInput = ref(null)
const categories = ref([])
const units = ref([])
// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。
const codeEdited = ref(false)
const isCreating = computed(() => dialogMode.value === 'create')
const isEditing = computed(() => dialogMode.value === 'edit')
const dialogTitle = computed(() => isCreating.value ? '新增物料' : '物料详情')
// 小类还没选时编码框必然是空的,提示用户先把分类选完:选完才会自动填入编码,
// 或者由"请手工填写"的提示接手。
const codePlaceholder = computed(() => {
if (!isCreating.value || form.category_id) return ''
return '请先选择大类类型与小类类型'
})
const majorCategories = computed(() => {
const active = getMajorCategories(categories.value)
const selected = categories.value.find(item => item.id === form.major_category_id)
return selected && !active.some(item => item.id === selected.id) ? [...active, selected] : active
})
const minorCategories = computed(() => {
const active = getMinorCategories(categories.value, form.major_category_id)
const selected = categories.value.find(item => item.id === form.category_id && item.parent_id === form.major_category_id)
return selected && !active.some(item => item.id === selected.id) ? [...active, selected] : active
})
function categoryLabel(category, withCode = false) {
const name = withCode ? `${category.code} ${category.name}` : category.name
return category.status === 'active' ? name : `${name}(已停用)`
}
function minorCategoryLabel(category, majorId) {
const major = categories.value.find(item => item.id === majorId)
return major && major.status !== 'active' ? category.name : categoryLabel(category)
}
function handleImageUpload(e) {
const file = e.target.files[0]
if (file) {
imagePreview.value = URL.createObjectURL(file)
}
}
const form = reactive(createMaterialForm())
onMounted(async () => {
materialsStore.fetchMaterials()
// 用含作废的那份选项:这一页的列表查询走 status='all',本身就显示着作废物料,
// 检索框却只给有效物料的话,作废的那些就搜不出来(而这一页正是恢复它们的入口)。
// 业务单据页(入库/出库/申请)仍用只有有效物料的 materialOptions。
materialsStore.fetchHistoricalMaterialOptions()
const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()])
categories.value = categoryResponse.data || []
units.value = (unitResponse.data || []).filter(item => item.status === 'active')
})
function syncCategory(id) {
form.category = categories.value.find(item => item.id === id)?.code || ''
// 手改过的编码保留用户的输入,没改过才跟着新分类的规则重算
if (!codeEdited.value) generateMaterialCode()
}
function syncMajorCategory() {
form.category_id = null
form.category = ''
// 大类换了,原来的小类规则不再适用;同样不动用户手改过的编码
if (!codeEdited.value) {
form.material_code = ''
}
}
// 按当前小类的编码规则填编码:有规则自动填,没规则就清空并弹窗让用户手工填写。
async function generateMaterialCode() {
if (!isCreating.value) return
if (!form.category_id) {
form.material_code = ''
return
}
try {
const response = await codeRulesApi.next(form.category_id)
const { available, code } = response.data || {}
if (available) {
form.material_code = code
} else {
form.material_code = ''
// 用模态框而不是会自动消失的 toast:这条提示要等用户点掉才走
ElMessageBox.alert('该小类未配置编码规则,请手工填写物料编码', '提示', {
confirmButtonText: '知道了',
type: 'warning'
// ESC 或点遮罩关掉时 alert 会 reject,这里不做处理
}).catch(() => {})
}
} catch (e) {
// 预览失败不该挡住填表:留空让用户手工填写,失败原因由拦截器提示
form.material_code = ''
}
}
// 「重新生成」:把编码交回自动生成,按当前分类规则重算
function regenerateMaterialCode() {
codeEdited.value = false
generateMaterialCode()
}
function markCodeEdited() {
if (isCreating.value) codeEdited.value = true
}
function getMajorCategoryDisplay(row) {
const { major } = resolveCategoryPath(categories.value, row.category_id)
if (major) return categoryLabel(major, true)
return materialsStore.getCategoryDisplay(row.category)
}
function getMinorCategoryDisplay(row) {
const { major, minor } = resolveCategoryPath(categories.value, row.category_id)
return minor ? minorCategoryLabel(minor, major?.id) : '-'
}
function syncUnit(id) {
form.unit = units.value.find(item => item.id === id)?.code || ''
}
function getUnitDisplay(row) {
return units.value.find(item => item.id === row.base_unit_id)?.name || row.unit || '-'
}
function handleSearch() {
materialsStore.fetchMaterials(1, searchKeyword.value)
}
function materialFilter(val) {
materialSearchText.value = val
}
function getFilteredMaterials() {
// 同 onMounted 的说明:候选要含作废物料,才对得上面向全量的列表查询。
return searchMaterialOptions(materialsStore.historicalMaterialOptions, materialSearchText.value)
}
function handlePageChange(page) {
materialsStore.fetchMaterials(page, searchKeyword.value)
}
function showAddModal() {
editingId.value = null
detailRow.value = null
dialogMode.value = 'create'
Object.assign(form, createMaterialForm())
codeEdited.value = false
imagePreview.value = ''
if (imageInput.value) imageInput.value.value = ''
dialogVisible.value = true
}
function showDetail(row) {
detailRow.value = row
editingId.value = null
dialogMode.value = 'detail'
Object.assign(form, createMaterialForm())
imagePreview.value = ''
if (imageInput.value) imageInput.value.value = ''
dialogVisible.value = true
}
function startEdit() {
if (!detailRow.value) return
editingId.value = detailRow.value.id
Object.assign(form, createMaterialForm(detailRow.value, categories.value))
// 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖
codeEdited.value = true
imagePreview.value = ''
if (imageInput.value) imageInput.value.value = ''
dialogMode.value = 'edit'
}
function cancelEdit() {
if (!isEditing.value) return
Object.assign(form, createMaterialForm())
editingId.value = null
imagePreview.value = ''
if (imageInput.value) imageInput.value.value = ''
dialogMode.value = 'detail'
}
function handleDialogClose() {
cancelEdit()
if (isCreating.value) Object.assign(form, createMaterialForm())
}
async function handleSave() {
if (!isCreating.value && !isEditing.value) return
if (!form.major_category_id || !form.category_id) {
ElMessage.warning('请选择大类类型和小类类型')
return
}
// 小类没有编码规则时编码框是空的,得让用户先补上
if (!form.material_code) {
ElMessage.warning('请填写物料编码')
return
}
// 后端对名称也标了 binding:"required",前端先拦下来,避免抛回原始校验错误
if (!form.name.trim()) {
ElMessage.warning('请填写物料名称')
return
}
saveLoading.value = true
try {
const formData = toMaterialPayload(form)
const file = imageInput.value?.files[0]
if (file) {
const uploadForm = new FormData()
uploadForm.append('image', file)
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${authStore.token}` },
body: uploadForm
})
if (!res.ok) {
ElMessage.error('图片上传失败')
return
}
const data = await res.json()
formData.material_image = (data.data && data.data.url) ? data.data.url : ''
}
if (editingId.value) {
const response = await materialsStore.updateMaterial(editingId.value, formData)
detailRow.value = { ...detailRow.value, ...formData, ...response?.data }
cancelEdit()
ElMessage.success('物料更新成功')
} else {
await materialsStore.createMaterial(formData)
ElMessage.success('物料创建成功')
// store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得
// 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。
searchKeyword.value = ''
dialogVisible.value = false
}
} catch (e) {
// 接口失败的原因已由拦截器弹过中文提示,这里只兜住它覆盖不到的异常,
// 避免出现"点了保存没反应"
if (!e?.response) {
console.error('保存物料失败:', e)
ElMessage.error(errorText(e, '保存失败'))
}
} finally {
saveLoading.value = false
}
}
function handleDelete(id) {
deleteId.value = id
deleteDialogVisible.value = true
}
// 这里不能吞异常:DeleteConfirm 靠它判断该不该关弹窗,吞掉会让失败时误关。
// 错误提示本来由 axios 拦截器统一负责,不吞也不会漏给用户看。
async function confirmDelete() {
await materialsStore.obsoleteMaterial(deleteId.value)
ElMessage.success('物料已作废')
}
async function handleRestore(id) {
try {
await materialsStore.restoreMaterial(id)
ElMessage.success('物料已恢复')
} catch (e) {
// 错误已由 API 层处理
}
}
</script>
<style scoped>
.materials-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.search-bar {
display: flex;
width: 100%;
gap: 10px;
margin-bottom: 15px;
}
.search-native-input {
width: 250px;
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
outline: none;
background: #fff;
box-sizing: border-box;
}
.search-native-input:focus {
border-color: #409eff;
}
:deep(.el-table__cell.operation-column) {
border-left: 1px solid var(--el-table-border-color) !important;
}
/* 一行里横排「输入框 + 重新生成」;作为表单项内容区的 flex item,flex:1 让它
占满整行 —— 不设的话会收缩到 el-input 的固有宽度(约 177px),比表单里其它
字段窄一截。 */
.material-code-field {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
</style>