652 lines
24 KiB
Vue
652 lines
24 KiB
Vue
<template>
|
||
<div class="materials-page">
|
||
<div class="page-header">
|
||
<h2>物料主数据</h2>
|
||
<div class="header-actions">
|
||
<el-button @click="handleDownloadTemplate">下载模板</el-button>
|
||
<el-button @click="triggerImport">批量导入</el-button>
|
||
<el-button type="primary" @click="showAddModal">新增物料</el-button>
|
||
<input
|
||
ref="importInput"
|
||
type="file"
|
||
accept=".xlsx"
|
||
style="display: none"
|
||
@change="handleImportFile"
|
||
/>
|
||
</div>
|
||
</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-alert
|
||
v-if="isCreating && majorCategories.length === 0"
|
||
class="material-config-hint"
|
||
type="warning"
|
||
:closable="false"
|
||
title="本公司尚未配置物料分类"
|
||
description="请先到「基础配置 → 物料分类」添加分类,选好大类与小类后再填写物料编码建档。"
|
||
/>
|
||
<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" placeholder="未选择单位时,默认使用“个”" 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"
|
||
/>
|
||
|
||
<el-dialog v-model="importDialogVisible" title="批量导入结果" width="900px" @closed="handleImportDialogClosed">
|
||
<MaterialImportResult v-if="importResult" :result="importResult" @close="importDialogVisible = false" />
|
||
</el-dialog>
|
||
|
||
<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 { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard'
|
||
import { useSavingGuard } from '../composables/useSavingGuard'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
|
||
import AlertTip from '../components/business/AlertTip.vue'
|
||
import MaterialImportResult from '../components/business/MaterialImportResult.vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { createMaterialForm, toMaterialPayload } from './materialForm'
|
||
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
|
||
import { materialsApi } from '../api/materials'
|
||
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
|
||
import { searchMaterialOptions } from '../utils/materialSearch'
|
||
import { errorText } from '../utils/errorText'
|
||
import { formatDate } from '../utils/datetime'
|
||
import { getOrganizationId } from '../utils/organizationContext'
|
||
|
||
// 物料详情的展示沿用原先不带秒的格式
|
||
const MATERIAL_DATE_OPTIONS = {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
}
|
||
|
||
const materialsStore = useMaterialsStore()
|
||
const authStore = useAuthStore()
|
||
// 图片上传是本页唯一直接发起的写请求(其余写请求都走 materialsStore 自身的保存互斥);
|
||
// 上传要用保存互斥包住,飞行期间禁止切换公司。
|
||
const { runWithSaving } = useSavingGuard()
|
||
|
||
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 importInput = ref(null)
|
||
const importDialogVisible = ref(false)
|
||
const importResult = ref(null)
|
||
const importing = ref(false)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 批量导入入口:上传期间再点不该重新弹选择框
|
||
function triggerImport() {
|
||
if (importing.value) return
|
||
importInput.value?.click()
|
||
}
|
||
|
||
async function handleImportFile(event) {
|
||
const file = event.target.files?.[0]
|
||
// 清空 input,否则连续选同一个文件不会再触发 change
|
||
event.target.value = ''
|
||
if (!file) return
|
||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||
ElMessage.warning('只支持 .xlsx 文件')
|
||
return
|
||
}
|
||
if (file.size > 1 * 1024 * 1024) {
|
||
ElMessage.warning('文件不能超过 1MB')
|
||
return
|
||
}
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
importing.value = true
|
||
try {
|
||
// 导入是写请求:包进保存互斥,飞行期间禁止切换公司
|
||
const res = await runWithSaving(() => materialsApi.importFile(formData))
|
||
const result = res.data
|
||
await materialsStore.fetchMaterials(1, '', 'all')
|
||
searchKeyword.value = ''
|
||
ElMessage.success(`成功导入 ${result.success_rows} 条物料`)
|
||
} catch (e) {
|
||
// 校验失败时响应里带逐行明细,弹窗展示;其余错误走兜底提示
|
||
const detail = e?.response?.data?.data
|
||
if (detail && Array.isArray(detail.errors)) {
|
||
importResult.value = detail
|
||
importDialogVisible.value = true
|
||
} else {
|
||
// 导入请求关闭了全局提示,HTTP 错误和网络错误都由页面兜底。
|
||
console.error('批量导入失败:', e)
|
||
ElMessage.error(errorText(e, '批量导入失败'))
|
||
}
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
function handleImportDialogClosed() {
|
||
importResult.value = null
|
||
}
|
||
|
||
async function handleDownloadTemplate() {
|
||
try {
|
||
const res = await runWithSaving(() => materialsApi.downloadImportTemplate())
|
||
if (!res.ok) {
|
||
ElMessage.error('模板下载失败')
|
||
return
|
||
}
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = '物料导入模板.xlsx'
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
URL.revokeObjectURL(url)
|
||
} catch (e) {
|
||
console.error('下载模板失败:', e)
|
||
ElMessage.error('模板下载失败')
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
if (Array.from(form.name).length > 200) {
|
||
ElMessage.warning('物料名称过长,请小于200字')
|
||
return
|
||
}
|
||
if (Array.from(form.spec || '').length > 200) {
|
||
ElMessage.warning('物料规格过长,请小于200字')
|
||
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)
|
||
// 这里绕开了 axios 拦截器,组织头得手工补上,否则图片上传会缺少 X-Organization-ID。
|
||
const organizationId = getOrganizationId()
|
||
// 上传是保存物料流程中的直接写请求:包进保存互斥,飞行期间禁止切换公司;
|
||
// runWithSaving 内部是 try/finally,上传失败也会复位,不会把 busy 永久卡住。
|
||
const res = await runWithSaving(() => fetch('/api/upload', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${authStore.token}`,
|
||
...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {})
|
||
},
|
||
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 层处理
|
||
}
|
||
}
|
||
|
||
// 切换公司会重新挂载页面,正在编辑的弹窗连同其中的改动都会丢失。有未保存内容时先确认,
|
||
// 用户取消则保持当前公司不变(守卫返回 false)。
|
||
const hasUnsavedChanges = computed(
|
||
() => dialogVisible.value && (isCreating.value || isEditing.value)
|
||
)
|
||
useOrganizationSwitchGuard(() => hasUnsavedChanges.value)
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.materials-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.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;
|
||
}
|
||
.material-config-hint {
|
||
margin-bottom: 12px;
|
||
}
|
||
.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>
|