feat(production-master-data): add 异常不良管理功能

1. 新增设备类别API接口封装
2. 新增异常不良管理的CRUD、导入导出API
3. 添加异常不良管理页面路由与多语言配置
4. 新增文件工具类支持Excel读写下载
5. 实现完整的异常不良管理页面与导入弹窗
6. 新增功能测试流程文档
7. 安装xlsx依赖支持Excel操作
This commit is contained in:
sheng
2026-06-02 14:05:15 +08:00
parent a0192d9567
commit ddc715e17c
11 changed files with 1140 additions and 5 deletions

View File

@@ -0,0 +1,175 @@
<template>
<el-dialog
:visible.sync="visibleProxy"
:title="$t(key('import_exception_ng_data'))"
:width="width"
:close-on-click-modal="false"
@close="onClose"
>
<div>
<el-alert
:title="$t(key('import_file_format_tip'))"
:closable="false"
type="warning"
/>
<el-form ref="form" label-width="100px" style="margin-top:10px">
<el-form-item :label="$t(key('import_table'))">
<el-upload
action=""
:multiple="false"
:show-file-list="true"
:file-list="importFileList"
accept=".xls,.xlsx"
:http-request="onUpload"
>
<el-button size="mini" type="success">
{{ $t(key('select_file')) }}
</el-button>
<el-button
style="margin-left:10px"
size="mini"
type="primary"
:loading="downloadLoading"
@click="onDownloadTemplate"
>
{{ $t(key('download_template')) }}
</el-button>
</el-upload>
</el-form-item>
<el-form-item :label="$t(key('preview'))">
<el-table
v-loading="previewLoading"
:data="previewList"
height="350"
border
size="mini"
>
<el-table-column type="selection" width="55" />
<el-table-column :label="$t(key('device_category'))" prop="device_category_name" min-width="100" />
<el-table-column :label="$t(key('exception_ng_category'))" prop="type" min-width="100" />
<el-table-column :label="$t(key('ng_code'))" prop="number" min-width="100" />
<el-table-column :label="$t(key('ng_name'))" prop="explain" min-width="120" />
<el-table-column :label="$t(key('remark'))" prop="note" min-width="120" />
</el-table>
</el-form-item>
</el-form>
</div>
<div slot="footer" class="dialog-footer">
<el-button size="mini" @click="visibleProxy = false">
{{ $t(key('cancel')) }}
</el-button>
<el-button type="primary" size="mini" :loading="submitting" @click="onSubmit">
{{ $t(key('confirm')) }}
</el-button>
</div>
</el-dialog>
</template>
<script>
import { i18nMixin } from '@/composables/useI18n'
import { getImportTemplate, productNgInfoImport } from '@/api/production-master-data/product-ng-info'
import { downloadRename, readExcel } from '@/utils/file'
const EXPECTED_COLUMNS = ['设备类别', '异常不良类别', '异常不良编码', '异常不良名称', '备注']
export default {
name: 'ProductNgInfoImportDialog',
mixins: [i18nMixin('page.production_master_data.product_model.product_ng_info')],
props: {
visible: Boolean,
width: { type: String, default: '50%' }
},
data () {
return {
submitting: false,
downloadLoading: false,
previewLoading: false,
importFileList: [],
previewList: []
}
},
computed: {
visibleProxy: {
get () { return this.visible },
set (val) { this.$emit('update:visible', val) }
}
},
watch: {
visible (val) {
if (val) this.reset()
}
},
methods: {
reset () {
this.importFileList = []
this.previewList = []
this.submitting = false
this.previewLoading = false
},
onDownloadTemplate () {
this.downloadLoading = true
getImportTemplate({})
.then(res => { downloadRename(res, 'xlsx', this.key('import_template_name')) })
.finally(() => { this.downloadLoading = false })
},
onUpload (e) {
const file = e.file
if (!file) {
this.$message.error(this.$t(this.key('file_not_exist')))
return
}
const lower = file.name.toLowerCase()
if (!/\.(xls|xlsx)$/.test(lower)) {
this.$message.error(this.$t(this.key('upload_format_error')))
return
}
this.importFileList = [file]
this.previewLoading = true
readExcel(file)
.then(res => {
if (!res || !res.length) return
const firstRow = res[0]
for (const col of EXPECTED_COLUMNS) {
if (!Object.prototype.hasOwnProperty.call(firstRow, col)) {
this.$message.error(this.$t(this.key('file_column_missing'), { title: col }))
return
}
}
this.previewList = res.map(row => ({
device_category_name: row['设备类别'],
type: row['异常不良类别'],
number: row['异常不良编码'],
explain: row['异常不良名称'],
note: row['备注']
}))
})
.catch(err => {
this.$message.error(err.message || this.$t(this.key('upload_format_error')))
})
.finally(() => { this.previewLoading = false })
},
onSubmit () {
if (!this.previewList.length) {
this.$message.error(this.$t(this.key('please_import_data')))
return
}
this.submitting = true
productNgInfoImport({
import_data: JSON.stringify(this.previewList)
})
.then(() => {
this.$message.success(this.$t(this.key('operation_success')))
this.visibleProxy = false
this.$emit('saved')
})
.finally(() => { this.submitting = false })
},
onClose () {
this.reset()
}
}
}
</script>

View File

@@ -0,0 +1,459 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('device_category'))">
<el-select
v-model="search.device_category_id"
:placeholder="$t(key('select_device_category'))"
clearable
filterable
style="width:200px"
@focus="loadDeviceCategories"
>
<el-option
v-for="item in deviceCategoryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('query_type'))">
<el-select
v-model="search.type"
:placeholder="$t(key('select_query_type'))"
clearable
style="width:200px"
>
<el-option :label="$t(key('type_error'))" value="ERR" />
<el-option :label="$t(key('type_ng'))" value="NG" />
</el-select>
</el-form-item>
<el-form-item :label="$t(key('ng_code'))">
<el-input
v-model="search.number"
:placeholder="$t(key('enter_ng_code'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('ng_name'))">
<el-input
v-model="search.explain"
:placeholder="$t(key('enter_ng_name'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="onSearch">
{{ $t(key('search')) }}
</el-button>
<el-button icon="el-icon-refresh" @click="onReset">
{{ $t(key('reset')) }}
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<page-table
ref="pageTable"
:columns="columns"
:data="tableData"
:loading="loading"
:toolbar-buttons="toolbarButtons"
:row-buttons="rowButtons"
:pagination="pagination"
help-url="/help/product-ng-info"
:help-text="$t(ckey('help'))"
auto-height
@page-change="onPageChange"
@selection-change="onSelect"
/>
<page-dialog-form
ref="dialogForm"
:visible.sync="dialogVisible"
:title="dialogTitle"
:width="'35%'"
:form-cols="formCols"
:form-data="formData"
:rules="rules"
:label-width="'120px'"
:submitting="submitting"
:confirm-text="key('confirm')"
:cancel-text="key('cancel')"
@submit="onDialogSubmit"
@close="onDialogClose"
/>
<product-ng-info-import-dialog
:visible.sync="importVisible"
@saved="onImportSaved"
/>
</d2-container>
</template>
<script>
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { confirmMixin } from '@/composables/useConfirmHandle'
import {
getProductNgInfoList,
createProductNgInfo,
editProductNgInfo,
deleteProductNgInfo,
productNgInfoExportTask
} from '@/api/production-master-data/product-ng-info'
import { getDeviceCategoryAll } from '@/api/production-master-data/device-category'
import PageTable from '@/components/page-table'
import PageDialogForm from '@/components/page-dialog-form'
import ProductNgInfoImportDialog from './components/ImportDialog/index.vue'
export default {
name: 'production-master-data-product-ng-info',
components: { PageTable, PageDialogForm, ProductNgInfoImportDialog },
mixins: [i18nMixin('page.production_master_data.product_model.product_ng_info'), confirmMixin],
data () {
return {
loading: false,
submitting: false,
tableData: [],
selectedRows: [],
dialogVisible: false,
dialogTitle: '',
editId: null,
handleType: 'create',
importVisible: false,
search: { device_category_id: '', type: '', number: '', explain: '' },
pagination: { current: 1, size: 10, total: 0 },
deviceCategoryOptions: [],
formData: { device_category_id: '', type: '', number: '', explain: '', note: '' },
rules: {
device_category_id: [
{ required: true, message: this.key('select_device_category'), trigger: 'change' }
],
type: [
{ required: true, message: this.key('select_type'), trigger: 'change' }
],
number: [
{ required: true, message: this.key('enter_ng_code'), trigger: 'blur' },
{ min: 1, max: 100, message: this.key('remark_length'), trigger: 'blur' }
],
explain: [
{ required: true, message: this.key('enter_ng_name'), trigger: 'blur' },
{ min: 1, max: 100, message: this.key('remark_length'), trigger: 'blur' }
]
},
formCols: [],
columns: [],
toolbarButtons: [],
rowButtons: []
}
},
created () {
this.formCols = [
[
{
type: 'select',
prop: 'device_category_id',
label: this.key('device_category'),
placeholder: this.key('select_device_category'),
clearable: true,
style: { width: '90%' },
options: [],
keys: { label: 'name', value: 'id' },
onFocus: this.onFormDeviceCategoryFocus
}
],
[
{
type: 'select',
prop: 'type',
label: this.key('type'),
placeholder: this.key('select_type'),
clearable: true,
style: { width: '90%' },
options: [
{ name: this.key('type_error'), code: 'ERR' },
{ name: this.key('type_ng'), code: 'NG' }
],
keys: { label: 'name', value: 'code' }
}
],
[
{
type: 'input',
prop: 'number',
label: this.key('ng_code'),
placeholder: this.key('enter_ng_code'),
clearable: true,
style: { width: '90%' }
}
],
[
{
type: 'input',
prop: 'explain',
label: this.key('ng_name'),
placeholder: this.key('enter_ng_name'),
clearable: true,
style: { width: '90%' }
}
],
[
{
type: 'input',
inputType: 'textarea',
prop: 'note',
label: this.key('remark'),
placeholder: this.key('enter_remark'),
autosize: { minRows: 2, maxRows: 6 },
clearable: true,
style: { width: '90%' }
}
]
]
this.columns = useTableColumns([
{ type: 'selection', width: 55 },
{ prop: 'device_category_name', label: this.key('device_category'), minWidth: 140 },
{ prop: 'type', label: this.key('exception_ng_category'), minWidth: 120 },
{ prop: 'number', label: this.key('ng_code'), minWidth: 140 },
{ prop: 'explain', label: this.key('ng_name'), minWidth: 140 },
{ prop: 'note', label: this.key('remark'), minWidth: 200 },
{ prop: '_actions', label: this.key('operation'), width: 180, fixed: 'right' }
])
const btns = useTableButtons({
toolbar: [
{
key: 'add',
label: this.key('add'),
icon: 'el-icon-plus',
type: 'primary',
auth: '/production_configuration/product_model/product_ng_info/create',
onClick: this.openAdd
},
{
key: 'batch-delete',
label: this.key('batch_delete'),
icon: 'el-icon-delete',
type: 'danger',
auth: '/production_configuration/product_model/product_ng_info/delete',
onClick: this.handleBatchDelete
},
{
key: 'import',
label: this.key('import'),
icon: 'el-icon-upload2',
color: '#3CBA92',
auth: '/production_configuration/product_model/product_ng_info/create',
onClick: this.openImport
},
{
key: 'export',
label: this.key('export'),
icon: 'el-icon-download',
color: '#35C2EE',
auth: '/production_configuration/product_model/product_ng_info/export',
onClick: this.handleExport
}
],
row: [
{
key: 'edit',
label: this.key('edit'),
icon: 'el-icon-edit',
auth: '/production_configuration/product_model/product_ng_info/edit',
onClick: this.openEdit
},
{
key: 'delete',
label: this.key('delete'),
icon: 'el-icon-delete',
color: 'danger',
auth: '/production_configuration/product_model/product_ng_info/delete',
onClick: this.handleDelete
}
]
}, this.$permission)
this.toolbarButtons = btns.toolbarButtons
this.rowButtons = btns.rowButtons
this.fetchData()
},
methods: {
async fetchData () {
this.loading = true
try {
const res = await getProductNgInfoList({
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
})
const data = Array.isArray(res) ? res : (res.data || {})
const list = Array.isArray(data) ? data : (data.data || [])
const total = Array.isArray(data) ? data.length : (data.count || 0)
this.tableData = list
this.pagination.total = total
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.search = { device_category_id: '', type: '', number: '', explain: '' }
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
onSelect (rows) {
this.selectedRows = rows
},
async loadDeviceCategories () {
if (this.deviceCategoryOptions.length) return
try {
const res = await getDeviceCategoryAll({})
const list = Array.isArray(res) ? res : (res.data || [])
this.deviceCategoryOptions = Array.isArray(list) ? list : []
this.searchFormDeviceCategoryUpdate()
} catch { /* ignore */ }
},
searchFormDeviceCategoryUpdate () {
const catCol = this.formCols[0] && this.formCols[0][0]
if (catCol && catCol.type === 'select') {
catCol.options = this.deviceCategoryOptions
}
},
onFormDeviceCategoryFocus () {
this.loadDeviceCategories()
},
resetForm () {
this.formData = { device_category_id: '', type: '', number: '', explain: '', note: '' }
this.editId = null
this.searchFormDeviceCategoryUpdate()
},
openAdd () {
this.handleType = 'create'
this.dialogTitle = this.key('add_exception_ng_category')
this.loadDeviceCategories()
this.$nextTick(() => {
this.$refs.dialogForm && this.$refs.dialogForm.reset()
this.resetForm()
this.dialogVisible = true
})
},
openEdit (row) {
this.handleType = 'edit'
this.dialogTitle = this.key('edit_exception_ng_category')
this.editId = row.id
this.loadDeviceCategories()
this.formData = {
device_category_id: row.device_category_id || '',
type: row.type || '',
number: row.number || '',
explain: row.explain || '',
note: row.note || ''
}
this.dialogVisible = true
},
async onDialogSubmit () {
this.submitting = true
try {
if (this.handleType === 'create') {
await createProductNgInfo(this.formData)
} else {
await editProductNgInfo({ ...this.formData, id: this.editId })
}
this.$message.success(this.$t(this.key('operation_success')))
this.dialogVisible = false
this.fetchData()
} finally {
this.submitting = false
}
},
onDialogClose () {
this.resetForm()
},
async handleDelete (row) {
await this.$confirmAction(
{
message: this.key('confirm_delete'),
title: this.key('prompt')
},
() => deleteProductNgInfo({ id: [row.id] })
)
this.$message.success(this.$t(this.key('operation_success')))
this.pagination.current = Math.min(
this.pagination.current,
Math.ceil((this.pagination.total - 1) / this.pagination.size) || 1
)
this.fetchData()
},
async handleBatchDelete () {
if (!this.selectedRows.length) {
this.$message.error(this.$t(this.key('please_select_data')))
return
}
const ids = this.selectedRows.map(r => r.id)
await this.$confirmAction(
{
message: this.key('confirm_batch_delete'),
title: this.key('prompt')
},
() => deleteProductNgInfo({ id: ids })
)
this.$message.success(this.$t(this.key('operation_success')))
this.fetchData()
},
openImport () {
this.importVisible = true
},
onImportSaved () {
this.fetchData()
},
async handleExport () {
try {
await this.$confirm(
this.$t(this.key('export_confirm_tip')),
this.$t(this.key('prompt')),
{ confirmButtonText: this.$t(this.key('confirm')), cancelButtonText: this.$t(this.key('cancel')), type: 'warning', center: true }
)
} catch {
this.$message({ type: 'info', message: this.$t(this.key('operation_cancelled')) })
return
}
try {
await productNgInfoExportTask({
...this.search,
action: 'download'
})
this.$message.success(this.$t(this.key('create_download_task_success')))
this.$router.push({ name: 'task' })
} catch { /* handled by interceptor */ }
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
/deep/ .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
</style>