feat: migrate planning batch management modules
Some checks failed
Release pipeline / publish (push) Has been cancelled
Release pipeline / Always run job (push) Has been cancelled

- migrate batch list, tray tracking, and batch defect report pages to V2

- add planning production APIs, workerman helper, routes, and i18n entries

- add markdown migration task list generated from the Webman function matrix
This commit is contained in:
sheng
2026-06-22 14:13:01 +08:00
parent 3f546564cc
commit b44e031e74
11 changed files with 2553 additions and 652 deletions

View File

@@ -0,0 +1,174 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" :model="search" ref="searchForm" size="mini">
<el-form-item :label="$t(key('batch'))" prop="batch">
<el-input
v-model="search.batch"
:placeholder="$t(key('enter_batch_no'))"
clearable
style="width:220px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('time_range'))" prop="time">
<el-date-picker
v-model="search.time"
format="yyyy-MM-dd HH:mm:ss"
value-format="yyyy-MM-dd HH:mm:ss"
type="datetimerange"
:range-separator="$t(key('to'))"
:start-placeholder="$t(key('time_start'))"
:end-placeholder="$t(key('time_end'))"
style="width:360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :disabled="loading" @click="onSearch">
{{ $t(key('query')) }}
</el-button>
<el-button icon="el-icon-refresh" :disabled="loading" @click="onReset">
{{ $t(key('reset')) }}
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<page-table
:columns="columns"
:data="tableData"
:loading="loading"
:toolbar-buttons="[]"
:row-buttons="[]"
:pagination="pagination"
:table-attrs="{ size: 'mini' }"
auto-height
@page-change="onPageChange"
>
<template #col-is_batch_finish="{ row }">
<el-tag v-if="row.is_batch_finish == 1" type="success">
{{ $t(key('finished')) }}
</el-tag>
<el-tag v-else type="danger">
{{ $t(key('incomplete')) }}
</el-tag>
</template>
<template #empty>
<el-empty :description="$t('暂无数据')" :image-size="80" />
</template>
</page-table>
</d2-container>
</template>
<script>
import { useTableColumns } from '@/composables/useTableColumns'
import { i18nMixin } from '@/composables/useI18n'
import PageTable from '@/components/page-table'
import { getBatchDefectReport } from '@/api/planning-production/batch-defect-report'
export default {
name: 'planning-production-batch-defect-report',
components: { PageTable },
mixins: [i18nMixin('page.planning_production.batch_management.batch_defect_report')],
data () {
return {
loading: false,
search: {
batch: '',
time: []
},
tableData: [],
title1: [],
title2: [],
pagination: {
current: 1,
size: 10,
total: 0
}
}
},
computed: {
columns () {
return useTableColumns(this.normalizeColumns(this.title1), {
selectionWidth: 0,
indexWidth: 0
})
}
},
mounted () {
this.fetchData()
},
methods: {
normalizeColumns (source) {
if (!Array.isArray(source) || !source.length) {
return [
{ prop: 'batch', label: this.key('batch_no'), minWidth: 150 },
{ prop: 'is_batch_finish', label: this.key('status'), width: 120, slot: 'is_batch_finish' }
]
}
return source.map((item, index) => {
const prop = item.prop || item.field || item.key || item.name || `col_${index}`
const label = item.name || item.label || prop
const column = {
prop,
label,
minWidth: item.minWidth || item.width || 120
}
if (item.width) {
column.width = item.width
delete column.minWidth
}
if (prop === 'is_batch_finish') {
column.slot = 'is_batch_finish'
}
return column
})
},
buildParams () {
return {
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
}
},
fetchData () {
this.loading = true
getBatchDefectReport(this.buildParams())
.then(res => {
const data = res.data || {}
this.tableData = data.data || []
this.title1 = data.title1 || []
this.title2 = data.title2 || []
this.pagination.total = Number(data.count || 0)
})
.finally(() => {
this.loading = false
})
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.$refs.searchForm.resetFields()
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination = page
this.fetchData()
}
}
}
</script>
<style lang="scss" scoped>
.search-bar {
margin-bottom: -18px;
}
</style>

View File

@@ -0,0 +1,771 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('batch_no'))">
<el-input
v-model="search.batch"
:placeholder="$t(key('enter_batch_no'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('process_flow'))">
<el-select
v-model="search.flow_id"
:placeholder="$t(key('select_process_flow'))"
clearable
filterable
style="width:200px"
@focus="loadFlowOptions"
>
<el-option
v-for="item in flowOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('product_model'))">
<el-select
v-model="search.product_model_id"
:placeholder="$t(key('select_product_model'))"
clearable
filterable
style="width:200px"
@focus="loadProductOptions"
>
<el-option
v-for="item in productOptions"
:key="item.product_model_id || item.id"
:label="item.name || item.code"
:value="item.product_model_id || item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('factory_area'))">
<el-select
v-model="search.area_id"
:placeholder="$t(key('select_area'))"
clearable
filterable
style="width:180px"
@focus="loadAreaOptions"
@change="onSearchAreaChange"
>
<el-option
v-for="item in areaOptions"
:key="item.area_id || item.id"
:label="item.name"
:value="item.area_id || item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('production_line'))">
<el-select
v-model="search.line_id"
:placeholder="$t(key('select_line'))"
clearable
filterable
style="width:180px"
@focus="loadLineOptions(search.area_id)"
>
<el-option
v-for="item in searchLineOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('create_time'))">
<el-date-picker
v-model="search.create_time"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
range-separator="-"
:start-placeholder="$t(key('start_time'))"
:end-placeholder="$t(key('end_time'))"
style="width:330px"
/>
</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-text="$t(ckey('help'))"
auto-height
@page-change="onPageChange"
/>
<el-dialog
:visible.sync="dialogVisible"
:title="$t(dialogTitle)"
width="35%"
:close-on-click-modal="false"
:destroy-on-close="true"
@close="onDialogClose"
>
<el-form ref="batchForm" :model="formData" :rules="rules" label-width="130px" size="mini">
<el-form-item :label="$t(key('process_flow'))" prop="flow_id">
<el-select
v-model="formData.flow_id"
:placeholder="$t(key('select_process_flow'))"
:disabled="isEdit"
clearable
filterable
style="width:90%"
@focus="loadFlowOptions"
>
<el-option
v-for="item in flowOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('batch_no'))" prop="batch">
<el-input
v-model="formData.batch"
:placeholder="$t(key('enter_batch_no'))"
:disabled="isEdit"
clearable
style="width:90%"
/>
</el-form-item>
<el-form-item :label="$t(key('factory_area'))" prop="area_id">
<el-select
v-model="formData.area_id"
:placeholder="$t(key('select_area'))"
:disabled="isEdit"
clearable
filterable
style="width:90%"
@focus="loadAreaOptions"
@change="onFormAreaChange"
>
<el-option
v-for="item in areaOptions"
:key="item.area_id || item.id"
:label="item.name"
:value="item.area_id || item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('production_line'))" prop="line_id">
<el-select
v-model="formData.line_id"
:placeholder="$t(key('select_line'))"
:disabled="isEdit"
clearable
filterable
style="width:90%"
@focus="loadLineOptions(formData.area_id)"
>
<el-option
v-for="item in formLineOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('planned_completion_date'))" prop="planned_completion_date">
<el-date-picker
v-model="formData.planned_completion_date"
type="date"
value-format="yyyy-MM-dd"
:placeholder="$t(key('select_planned_completion_date'))"
clearable
style="width:90%"
/>
</el-form-item>
<el-form-item :label="$t(key('planned_completion_quantity'))" prop="planned_completion_quantity">
<el-input-number
v-model="formData.planned_completion_quantity"
:min="1"
:step="1"
:precision="0"
controls-position="right"
style="width:90%"
/>
</el-form-item>
<el-form-item :label="$t(key('remark'))" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
:placeholder="$t(key('enter_remark'))"
clearable
style="width:90%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button size="mini" @click="dialogVisible = false">{{ $t(key('cancel')) }}</el-button>
<el-button size="mini" type="primary" :loading="submitting" @click="onDialogSubmit">
{{ $t(key('confirm')) }}
</el-button>
</template>
</el-dialog>
<el-dialog
:visible.sync="importVisible"
:title="$t(key('battery_batch_login'))"
width="50%"
:close-on-click-modal="false"
:destroy-on-close="true"
@close="resetImport"
>
<el-form label-width="140px" size="mini">
<el-alert :title="$t(key('upload_warning'))" :closable="false" type="warning" />
<el-form-item class="import-row" :label="$t(key('data_import_text_file'))">
<el-select
v-model="importBatch"
:placeholder="$t(key('select_batch'))"
clearable
filterable
style="width:220px"
@focus="loadBatchOptions"
>
<el-option
v-for="item in batchOptions"
:key="item.id || item.batch"
:label="item.batch"
:value="item.batch"
/>
</el-select>
<el-upload
ref="upload"
class="upload-inline"
action=""
accept=".txt"
:multiple="false"
:show-file-list="true"
:file-list="importFileList"
:http-request="handleTxtUpload"
:disabled="!importBatch"
>
<el-button size="mini" type="success" :disabled="!importBatch">
{{ $t(key('select_file')) }}
</el-button>
</el-upload>
<el-button size="mini" type="warning" @click="resetImport">
{{ $t(key('reset')) }}
</el-button>
</el-form-item>
<el-form-item :label="$t(key('preview'))">
<page-table
:columns="importColumns"
:data="importTableData"
:loading="importLoading"
:height="350"
:toolbar-buttons="[]"
:row-buttons="[]"
/>
</el-form-item>
<div class="import-count">{{ $t(key('quantity')) }}: {{ importTableData.length }}</div>
</el-form>
<template #footer>
<el-button size="mini" @click="importVisible = false">{{ $t(key('cancel')) }}</el-button>
<el-button size="mini" type="primary" :loading="importLoading" @click="submitImport">
{{ $t(key('confirm')) }}
</el-button>
</template>
</el-dialog>
</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 {
getBatchAll,
getBatchList,
createBatch,
editBatch,
deleteBatch,
getProcessBatch
} from '@/api/planning-production/batch-list'
import { getProcessRoutingList } from '@/api/production-master-data/process-routing'
import { getProductBatteryAll } from '@/api/production-master-data/product-management'
import { getFactoryAreaALL } from '@/api/production-master-data/factory-area'
import { getProductionLineList } from '@/api/production-master-data/production-line'
import { sendWorkerman } from '@/api/production-master-data/workerman'
import PageTable from '@/components/page-table'
export default {
name: 'planning-production-batch-list',
components: { PageTable },
mixins: [i18nMixin('page.planning_production.batch_management.batch_list'), confirmMixin],
data () {
const positiveInteger = (rule, value, callback) => {
if (value === '' || value === undefined || value === null) {
callback()
return
}
if (!/^[1-9][0-9]*$/.test(String(value))) {
callback(new Error(this.$t(this.key('enter_positive_integer'))))
return
}
callback()
}
return {
loading: false,
submitting: false,
importLoading: false,
tableData: [],
dialogVisible: false,
importVisible: false,
dialogTitle: '',
handleType: 'create',
editId: null,
search: {
batch: '',
flow_id: '',
product_model_id: '',
area_id: '',
line_id: '',
create_time: []
},
pagination: { current: 1, size: 10, total: 0 },
formData: this.getDefaultForm(),
rules: {
batch: [
{ required: true, message: this.key('enter_batch_no'), trigger: 'blur' },
{ min: 1, max: 100, message: this.key('length_1_100'), trigger: 'blur' }
],
flow_id: [
{ required: true, message: this.key('select_process_flow'), trigger: 'change' }
],
area_id: [
{ required: true, message: this.key('select_area'), trigger: 'change' }
],
line_id: [
{ required: true, message: this.key('select_line'), trigger: 'change' }
],
planned_completion_quantity: [
{ validator: positiveInteger, trigger: 'blur' }
]
},
columns: [],
toolbarButtons: [],
rowButtons: [],
importColumns: [],
flowOptions: [],
productOptions: [],
areaOptions: [],
searchLineOptions: [],
formLineOptions: [],
batchOptions: [],
importBatch: '',
importFileList: [],
importTableData: [],
hasActiveBattery: false
}
},
computed: {
isEdit () {
return this.handleType === 'edit'
}
},
created () {
this.columns = useTableColumns([
{ prop: 'batch', label: this.key('batch_no'), minWidth: 150 },
{ prop: 'planned_completion_date', label: this.key('planned_completion_date'), minWidth: 160 },
{ prop: 'planned_completion_quantity', label: this.key('planned_completion_quantity'), minWidth: 170 },
{ prop: 'flow_name', label: this.key('process_flow_name'), minWidth: 160 },
{ prop: 'area_name', label: this.key('factory_area'), minWidth: 130 },
{ prop: 'line_name', label: this.key('production_line'), minWidth: 130 },
{ prop: 'product_model_code', label: this.key('product_model'), minWidth: 150 },
{ prop: 'input_amount', label: this.key('input_quantity'), minWidth: 120 },
{ prop: 'remark', label: this.key('remark'), minWidth: 180 },
{ prop: 'create_time', label: this.key('create_time'), minWidth: 170 },
{ prop: '_actions', label: this.key('operation'), width: 180, fixed: 'right' }
], { selectionWidth: 0 })
this.importColumns = useTableColumns([
{ prop: 'batch', label: this.key('batch'), minWidth: 150 },
{ prop: 'battery_id', label: this.key('barcode'), minWidth: 180 },
{ prop: 'active', label: this.key('activation_status'), minWidth: 140 }
], { selectionWidth: 0 })
const btns = useTableButtons({
toolbar: [
{
key: 'add',
label: this.key('add'),
icon: 'el-icon-plus',
type: 'primary',
auth: '/planning_production/production_batch_management/batch/create',
onClick: this.openAdd
},
{
key: 'battery-login',
label: this.key('battery_batch_login'),
icon: 'el-icon-upload2',
type: 'success',
auth: '/planning_production/production_batch_management/batch/import',
onClick: this.openImport
}
],
row: [
{
key: 'edit',
label: this.key('edit'),
icon: 'el-icon-edit',
auth: '/planning_production/production_batch_management/batch/edit',
onClick: this.openEdit
},
{
key: 'delete',
label: this.key('delete'),
icon: 'el-icon-delete',
color: 'danger',
auth: '/planning_production/production_batch_management/batch/delete',
onClick: this.handleDelete
}
]
}, this.$permission)
this.toolbarButtons = btns.toolbarButtons
this.rowButtons = btns.rowButtons
this.fetchData()
},
methods: {
getDefaultForm () {
return {
flow_id: '',
batch: '',
area_id: '',
line_id: '',
planned_completion_date: '',
planned_completion_quantity: undefined,
remark: ''
}
},
normalizeListResponse (res) {
const data = Array.isArray(res) ? res : (res && res.data) || []
if (Array.isArray(data)) return { list: data, total: data.length }
return {
list: data.data || [],
total: data.count || 0
}
},
async fetchData () {
this.loading = true
try {
const res = await getBatchList({
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
})
const { list, total } = this.normalizeListResponse(res)
this.tableData = list
this.pagination.total = total
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.search = {
batch: '',
flow_id: '',
product_model_id: '',
area_id: '',
line_id: '',
create_time: []
}
this.searchLineOptions = []
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
onSearchAreaChange () {
this.search.line_id = ''
this.searchLineOptions = []
},
onFormAreaChange () {
this.formData.line_id = ''
this.formLineOptions = []
},
async loadFlowOptions () {
if (this.flowOptions.length) return
try {
const res = await getProcessRoutingList({ page_no: 1, page_size: 1000 })
this.flowOptions = this.normalizeListResponse(res).list
} catch { /* handled by interceptor */ }
},
async loadProductOptions () {
if (this.productOptions.length) return
try {
const res = await getProductBatteryAll({})
const data = Array.isArray(res) ? res : (res && res.data) || []
this.productOptions = Array.isArray(data) ? data : []
} catch { /* handled by interceptor */ }
},
async loadAreaOptions () {
if (this.areaOptions.length) return
try {
const res = await getFactoryAreaALL({})
const data = Array.isArray(res) ? res : (res && res.data) || []
this.areaOptions = Array.isArray(data) ? data : []
} catch { /* handled by interceptor */ }
},
async loadLineOptions (areaId) {
try {
const res = await getProductionLineList({
area_id: areaId || '',
page_no: 1,
page_size: 1000
})
const lines = this.normalizeListResponse(res).list
if (this.dialogVisible) {
this.formLineOptions = lines
} else {
this.searchLineOptions = lines
}
} catch { /* handled by interceptor */ }
},
openAdd () {
this.handleType = 'create'
this.dialogTitle = this.key('add_batch')
this.editId = null
this.formData = this.getDefaultForm()
this.formLineOptions = []
this.loadFlowOptions()
this.loadAreaOptions()
this.$nextTick(() => {
this.$refs.batchForm && this.$refs.batchForm.clearValidate()
this.dialogVisible = true
})
},
openEdit (row) {
this.handleType = 'edit'
this.dialogTitle = this.key('edit_batch')
this.editId = row.id
this.formData = {
flow_id: row.flow_id || '',
batch: row.batch || '',
area_id: row.area_id || '',
line_id: row.line_id || '',
planned_completion_date: row.planned_completion_date || '',
planned_completion_quantity: row.planned_completion_quantity || undefined,
remark: row.remark || ''
}
this.loadFlowOptions()
this.loadAreaOptions()
this.loadLineOptions(row.area_id)
this.dialogVisible = true
},
onDialogSubmit () {
this.$refs.batchForm.validate(async valid => {
if (!valid) {
this.$message.warning(this.$t(this.key('validation_failed')))
return
}
this.submitting = true
try {
if (this.handleType === 'create') {
await createBatch(this.formData)
} else {
await editBatch({ ...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.formData = this.getDefaultForm()
this.editId = null
this.formLineOptions = []
},
async handleDelete (row) {
await this.$confirmAction(
{
message: this.key('confirm_operation'),
title: this.key('prompt')
},
() => deleteBatch({ batch: row.batch })
)
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()
},
openImport () {
this.importVisible = true
this.importBatch = ''
this.importFileList = []
this.importTableData = []
this.hasActiveBattery = false
},
async loadBatchOptions () {
if (this.batchOptions.length) return
try {
const res = await getBatchAll({})
const data = Array.isArray(res) ? res : (res && res.data) || []
this.batchOptions = Array.isArray(data) ? data : []
} catch { /* handled by interceptor */ }
},
resetImport () {
this.importBatch = ''
this.importFileList = []
this.importTableData = []
this.hasActiveBattery = false
this.importLoading = false
if (this.$refs.upload) this.$refs.upload.clearFiles()
},
handleTxtUpload (upload) {
const file = upload.file
if (!file) {
this.$message.error(this.$t(this.key('file_not_exist')))
return
}
if (!/\.txt$/i.test(file.name)) {
this.$message.error(this.$t(this.key('file_format_error')))
return
}
if (!this.importBatch) {
this.$message.error(this.$t(this.key('select_batch')))
return
}
this.importLoading = true
const reader = new FileReader()
reader.onload = async event => {
try {
const lines = String(event.target.result || '')
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
if (!lines.length) {
this.$message.error(this.$t(this.key('upload_file_error')))
return
}
const res = await getProcessBatch({
battery: lines.map(batteryIds => ({ battery_ids: batteryIds }))
})
const data = res && res.data !== undefined ? res.data : res
if (data && data.status === 1 && Array.isArray(data.battery_id)) {
this.hasActiveBattery = false
this.importTableData = data.battery_id.map(batteryId => ({
battery_id: batteryId,
active: 0,
batch: this.importBatch
}))
} else {
const rows = Array.isArray(data) ? data : []
this.hasActiveBattery = rows.some(item => item.active === 1)
this.importTableData = rows.map(item => ({
...item,
batch: this.importBatch
}))
}
this.$message.success(this.$t(this.key('import_data_success')))
} finally {
this.importLoading = false
}
}
reader.onerror = () => {
this.importLoading = false
this.$message.error(this.$t(this.key('upload_file_error')))
}
reader.readAsText(file)
},
async submitImport () {
if (!this.importTableData.length) {
this.$message.error(this.$t(this.key('please_import_data')))
return
}
if (this.hasActiveBattery) {
this.$message.error(this.$t(this.key('active_battery_exist')))
return
}
this.importLoading = true
try {
await sendWorkerman({
sendData: {
action: 'set_single_login',
param: {
battery_ids: this.importTableData.map(row => row.battery_id),
device_code: 'WEBMAN_LOGIN',
batch: this.importTableData[0].batch,
lot: ''
}
}
})
this.$message.success(this.$t(this.key('operation_success')))
this.importVisible = false
this.resetImport()
this.fetchData()
} finally {
this.importLoading = false
}
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
/deep/ .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
.import-row /deep/ .el-form-item__content {
display: flex;
align-items: center;
gap: 12px;
}
.upload-inline {
display: inline-block;
}
.import-count {
margin-left: 140px;
line-height: 28px;
}
</style>

View File

@@ -0,0 +1,454 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('tray_no'))">
<el-input
v-model="search.tray"
:placeholder="$t(key('enter_tray_no'))"
clearable
style="width:220px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('batch_no'))">
<el-input
v-model="search.batch"
:placeholder="$t(key('enter_batch_no'))"
clearable
style="width:220px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('status'))">
<el-select
v-model="search.active"
:placeholder="$t(key('select_status'))"
clearable
style="width:180px"
>
<el-option :label="$t(key('stop'))" value="0" />
<el-option :label="$t(key('activated'))" value="1" />
</el-select>
</el-form-item>
<el-form-item :label="$t(key('login_time'))">
<el-date-picker
v-model="search.create_time"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
range-separator="-"
:start-placeholder="$t(key('start_time'))"
:end-placeholder="$t(key('end_time'))"
style="width:330px"
/>
</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
:columns="columns"
:data="tableData"
:loading="loading"
:toolbar-buttons="[]"
:row-buttons="rowButtons"
:pagination="pagination"
:help-text="$t(ckey('help'))"
auto-height
@page-change="onPageChange"
>
<template #col-status="{ row }">
<el-tag v-if="row.active == 1" type="success">{{ $t(key('activated')) }}</el-tag>
<el-tag v-else type="danger">{{ $t(key('stop')) }}</el-tag>
</template>
</page-table>
<el-drawer
:visible.sync="trayDrawerVisible"
:with-header="false"
size="100%"
append-to-body
>
<div class="drawer-header">
<el-page-header @back="trayDrawerVisible = false" :content="trayDrawerTitle" />
</div>
<el-row>
<el-col :span="trayTimeline.length ? 6 : 0" v-if="trayTimeline.length">
<div class="details-left">
<el-timeline class="timeline">
<el-timeline-item v-for="(item, index) in trayTimeline" :key="index">
<p>
{{ $t(key('process')) }}: {{ item.process_name }}
<span v-if="item.is_next_process_code == 1" class="current-process">
{{ $t(key('current_process')) }}
</span>
</p>
<el-card>
<p>{{ $t(key('start_time')) }}: {{ item.beginTime }}</p>
<p>{{ $t(key('end_time')) }}: {{ item.endTime }}</p>
<p>{{ $t(key('device_number')) }}: {{ item.device_code }}</p>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-col>
<el-col :span="trayTimeline.length ? 18 : 24">
<div class="drawer-main">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('battery_barcode'))">
<el-input
v-model="traySearch"
:placeholder="$t(key('please_enter_battery_barcode'))"
clearable
style="width:320px"
/>
</el-form-item>
</el-form>
<el-table :data="filteredTrayDetails" border height="calc(100vh - 160px)">
<el-table-column type="index" width="60" :label="$t(key('serial_number'))" />
<el-table-column min-width="160" prop="battery_id" :label="$t(key('battery_barcode'))">
<template #default="{ row }">
<span class="link-text" @click="openBatteryDetails(row)">{{ row.battery_id }}</span>
</template>
</el-table-column>
<el-table-column min-width="140" prop="batch" :label="$t(key('batch_no'))" />
<el-table-column min-width="140" prop="tray" :label="$t(key('tray_no'))" />
<el-table-column min-width="120" prop="lot" :label="$t(key('lot'))" />
<el-table-column min-width="130" prop="active" :label="$t(key('activation_status'))">
<template #default="{ row }">
<el-tag :type="row.active == 1 ? 'success' : 'warning'">
{{ row.active == 1 ? $t(key('activated')) : $t(key('stop')) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</el-col>
</el-row>
</el-drawer>
<el-drawer
:visible.sync="batteryDrawerVisible"
:with-header="false"
size="100%"
append-to-body
>
<div class="drawer-header">
<el-page-header @back="batteryDrawerVisible = false" :content="batteryDrawerTitle" />
</div>
<el-row v-loading="batteryLoading">
<el-col :span="batteryTimeline.length ? 6 : 0" v-if="batteryTimeline.length">
<div class="details-left">
<el-timeline class="timeline">
<el-timeline-item
v-for="(item, index) in batteryTimeline"
:key="index"
:icon="item.is_select === '1' ? 'el-icon-check' : ''"
:type="item.is_select === '1' ? 'success' : ''"
:color="item.is_select === '1' ? '#67C23A' : ''"
@click.native="selectBatteryTimeline(index)"
>
<p>
{{ $t(key('process')) }}: {{ item.process_name }}
<span v-if="item.is_next_process_code == 1" class="current-process">
{{ $t(key('current_process')) }}
</span>
</p>
<el-card :class="{ selected: item.is_select === '1' }">
<p>{{ $t(key('start_time')) }}: {{ item.beginTime }}</p>
<p>{{ $t(key('end_time')) }}: {{ item.endTime }}</p>
<p>{{ $t(key('device_number')) }}: {{ item.device_code }}</p>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-col>
<el-col :span="batteryTimeline.length ? 18 : 24">
<div class="drawer-main">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('project_name'))">
<el-input
v-model="batterySearch"
:placeholder="$t(key('enter_project_name_search'))"
clearable
style="width:320px"
/>
</el-form-item>
</el-form>
<el-table :data="filteredBatteryDetails" border height="calc(100vh - 160px)">
<el-table-column type="index" width="60" :label="$t(key('serial_number'))" />
<el-table-column min-width="180" prop="name" :label="$t(key('project_name'))" />
<el-table-column min-width="220" prop="content" :label="$t(key('content'))" />
</el-table>
</div>
</el-col>
</el-row>
</el-drawer>
</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 {
getBatchTrayList,
trayUnbinding,
getBatteryParam
} from '@/api/planning-production/batch-tray'
import { sendWorkerman } from '@/api/production-master-data/workerman'
import PageTable from '@/components/page-table'
export default {
name: 'planning-production-tray-tracking',
components: { PageTable },
mixins: [i18nMixin('page.planning_production.batch_management.tray_tracking'), confirmMixin],
data () {
return {
loading: false,
tableData: [],
pagination: { current: 1, size: 10, total: 0 },
search: {
tray: '',
batch: '',
active: '',
create_time: []
},
columns: [],
rowButtons: [],
trayDrawerVisible: false,
trayDrawerTitle: '',
trayTimeline: [],
trayDetailData: [],
traySearch: '',
batteryDrawerVisible: false,
batteryDrawerTitle: '',
batteryLoading: false,
batteryTimeline: [],
batteryDetailData: [],
batterySearch: ''
}
},
computed: {
filteredTrayDetails () {
const keyword = String(this.traySearch || '').toLowerCase()
if (!keyword) return this.trayDetailData
return this.trayDetailData.filter(row => String(row.battery_id || '').toLowerCase().includes(keyword))
},
filteredBatteryDetails () {
const keyword = String(this.batterySearch || '').toLowerCase()
if (!keyword) return this.batteryDetailData
return this.batteryDetailData.filter(row => String(row.name || '').toLowerCase().includes(keyword))
}
},
created () {
this.search.tray = this.$route.params.tray || ''
this.search.batch = this.$route.params.batch || ''
this.columns = useTableColumns([
{ prop: 'tray', label: this.key('tray_no'), minWidth: 140 },
{ prop: 'lot', label: this.key('lot'), minWidth: 120 },
{ prop: 'status', label: this.key('status'), slot: 'status', minWidth: 110 },
{ prop: 'batch', label: this.key('batch_no'), minWidth: 140 },
{ prop: 'flow_name', label: this.key('flow_name'), minWidth: 160 },
{ prop: 'next_process_code', label: this.key('next_process_code'), minWidth: 140 },
{ prop: 'actual_input_battery_count', label: this.key('loaded_battery_qty'), minWidth: 150 },
{ prop: 'create_time', label: this.key('login_time'), minWidth: 170 },
{ prop: '_actions', label: this.key('actions'), width: 220, fixed: 'right' }
], { selectionWidth: 0 })
const btns = useTableButtons({
row: [
{
key: 'detail',
label: this.key('detail'),
icon: 'el-icon-position',
auth: '/planning_production/production_batch_management/batch_tray/trace',
onClick: this.openTrayDetails
},
{
key: 'unbind',
label: this.key('unbind'),
icon: 'el-icon-unlock',
auth: '/planning_production/production_batch_management/batch_tray/unbinding',
cssStyle: { color: '#E6A23C', marginRight: '10px', cursor: 'pointer' },
onClick: this.handleTrayUnbinding
},
{
key: 'stop',
label: this.key('stop'),
icon: 'el-icon-close',
color: 'danger',
auth: '/planning_production/production_batch_management/batch_tray/inactivity',
onClick: this.handleTrayInactivity
}
]
}, this.$permission)
this.rowButtons = btns.rowButtons
this.fetchData()
},
methods: {
normalizeListResponse (res) {
const data = Array.isArray(res) ? res : (res && res.data) || []
if (Array.isArray(data)) return { list: data, total: data.length }
return {
list: data.data || [],
total: data.count || 0
}
},
async fetchData () {
this.loading = true
try {
const res = await getBatchTrayList({
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
})
const { list, total } = this.normalizeListResponse(res)
this.tableData = list
this.pagination.total = total
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.search = { tray: '', batch: '', active: '', create_time: [] }
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
openTrayDetails (row) {
this.trayDrawerTitle = `${row.tray || ''}${this.$t(this.key('tray_details'))}`
this.trayTimeline = row.date_log || []
this.trayDetailData = row.trayDetailsData || []
this.traySearch = ''
this.trayDrawerVisible = true
},
async openBatteryDetails (row) {
this.batteryDrawerTitle = `${row.battery_id || ''}${this.$t(this.key('battery_details'))}`
this.batteryDrawerVisible = true
this.batteryLoading = true
this.batterySearch = ''
try {
const res = await getBatteryParam({
subbatch: row.subbatch,
batch: row.batch,
tray: row.tray,
lot: row.lot,
battery_id: row.battery_id
})
const data = Array.isArray(res) ? res : (res && res.data) || []
this.batteryTimeline = data.map((item, index) => ({
...item,
is_select: index === 0 ? '1' : '0'
}))
this.batteryDetailData = this.batteryTimeline[0] ? (this.batteryTimeline[0].batteryDetailsData || []) : []
} finally {
this.batteryLoading = false
}
},
selectBatteryTimeline (index) {
this.batteryTimeline = this.batteryTimeline.map((item, itemIndex) => ({
...item,
is_select: itemIndex === index ? '1' : '0'
}))
this.batteryDetailData = this.batteryTimeline[index]
? (this.batteryTimeline[index].batteryDetailsData || [])
: []
},
async handleTrayUnbinding (row) {
await this.$confirmAction(
{
message: this.key('tray_unbind_confirm'),
title: this.key('prompt')
},
() => trayUnbinding({ batch: row.batch, tray: row.tray })
)
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 handleTrayInactivity (row) {
await this.$confirmAction(
{
message: this.key('tray_stop_confirm'),
title: this.key('prompt')
},
() => sendWorkerman({
sendData: {
action: 'set_tray_inactivity',
param: {
batch: row.batch,
tray: row.tray
}
}
})
)
this.$message.success(this.$t(this.key('operation_success')))
this.fetchData()
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
/deep/ .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
.drawer-header {
padding: 24px;
border-bottom: 1px solid #dcdfe6;
}
.details-left {
height: calc(100vh - 73px);
overflow-y: auto;
background: #f2f3f5;
}
.timeline {
padding-top: 20px;
}
.drawer-main {
padding: 20px 20px 0;
}
.current-process {
color: #f56c6c;
}
.link-text {
color: #409eff;
cursor: pointer;
}
.selected {
color: #fff;
background-color: #67c23a;
}
</style>