「作废」和「撤销作废」轮流出现在同一列,2 字对 4 字,宽度不一样,最右边按钮的 位置会参差。把「作废」改成「作废物料」,两边都是 4 个中文字,同字体同字号下自然 等宽,不依赖写死的 px 值——本项目的测试环境是 jsdom,不做布局计算,量不了像素, 写死的值只能靠猜。 顺带修了测试里的一个自欺:那行物料原本叫「作废物料」,和按钮文案撞名,而按钮 自身就在那一行里,用 toContain 断言行归属会恒为真。改成「旧螺丝」才真的在测。 测试补 1 条(锁住两个按钮的文案字数相同)。变异验证:把「作废物料」改回「作废」, 文案断言和字数断言都变红。
465 lines
17 KiB
Vue
465 lines
17 KiB
Vue
<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" size="small" type="primary" @click="showEditModal(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">
|
||
<el-form :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="`${category.code} ${category.name}`" :value="category.id" />
|
||
</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="category.name" :value="category.id" />
|
||
</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>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="detailVisible" title="物料详情" width="600px">
|
||
<el-descriptions :column="2" border v-if="detailRow">
|
||
<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>
|
||
</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 detailVisible = ref(false)
|
||
const deleteDialogVisible = ref(false)
|
||
const dialogTitle = ref('新增物料')
|
||
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(() => dialogTitle.value === '新增物料')
|
||
// 小类还没选时编码框必然是空的,提示用户先把分类选完:选完才会自动填入编码,
|
||
// 或者由"请手工填写"的提示接手。
|
||
const codePlaceholder = computed(() => {
|
||
if (!isCreating.value || form.category_id) return ''
|
||
return '请先选择大类类型与小类类型'
|
||
})
|
||
const majorCategories = computed(() => getMajorCategories(categories.value))
|
||
const minorCategories = computed(() => getMinorCategories(categories.value, form.major_category_id))
|
||
|
||
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 || []).filter(item => item.status === 'active')
|
||
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 `${major.code} ${major.name}`
|
||
return materialsStore.getCategoryDisplay(row.category)
|
||
}
|
||
|
||
function getMinorCategoryDisplay(row) {
|
||
return resolveCategoryPath(categories.value, row.category_id).minor?.name || '-'
|
||
}
|
||
|
||
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
|
||
dialogTitle.value = '新增物料'
|
||
Object.assign(form, createMaterialForm())
|
||
codeEdited.value = false
|
||
imagePreview.value = ''
|
||
if (imageInput.value) imageInput.value.value = ''
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function showDetail(row) {
|
||
detailRow.value = row
|
||
detailVisible.value = true
|
||
}
|
||
|
||
function showEditModal(row) {
|
||
editingId.value = row.id
|
||
dialogTitle.value = '编辑物料'
|
||
Object.assign(form, createMaterialForm(row, categories.value))
|
||
// 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖
|
||
codeEdited.value = true
|
||
imagePreview.value = ''
|
||
if (imageInput.value) imageInput.value.value = ''
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleSave() {
|
||
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) {
|
||
await materialsStore.updateMaterial(editingId.value, formData)
|
||
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>
|