Files
klp-oa/klp-ui/src/views/aps/quickSheet.vue

700 lines
22 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="aps-quick-sheet excel-theme">
<div class="sheet-toolbar">
<div class="sheet-actions">
<el-button size="small" icon="el-icon-refresh" @click="loadRows">刷新</el-button>
<el-button size="small" type="primary" icon="el-icon-finished" :loading="saving" @click="saveAll">保存</el-button>
<el-button size="small" icon="el-icon-plus" @click="addRow">新增行</el-button>
<el-button size="small" icon="el-icon-download" @click="exportCsv">导出</el-button>
<el-button size="small" icon="el-icon-setting" @click="columnSettingVisible = true">列设置</el-button>
<el-date-picker
v-model="filter.range"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
size="small"
style="width: 240px"
@change="onFilterChange"
/>
</div>
<div class="preset-bar">
<span class="preset-title">预设方案</span>
<el-button
v-for="line in lineOptions"
:key="line.lineId"
size="mini"
plain
@click="applyPreset(line)"
>
{{ line.lineName || line.lineCode || ('产线' + line.lineId) }}
</el-button>
</div>
</div>
<div class="sheet-body">
<el-table
ref="quickSheetTable"
v-loading="loading"
:data="displayRows"
border
size="mini"
class="excel-table"
>
<el-table-column label="序号" width="60" align="center" fixed="left">
<template slot-scope="scope">
{{ ((pager.pageNum - 1) * pager.pageSize) + scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
v-for="(col, colIndex) in displayColumns"
:key="col.prop || col.label"
:label="col.label"
:prop="col.prop"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'center'"
:fixed="fixedProps.includes(col.prop) ? 'left' : false"
:show-overflow-tooltip="col.showOverflowTooltip !== false"
>
<template slot-scope="scope">
<template v-if="isTimeField(col.prop)">
<el-date-picker
v-model="scope.row[col.prop]"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
size="mini"
style="width: 100%"
@change="onCellChange(scope.row)"
@input="onCellChange(scope.row)"
@keydown.native.enter.prevent="focusNextCell(scope.$index, colIndex, 'down')"
@keydown.native.tab.prevent="focusNextCell(scope.$index, colIndex, 'right')"
/>
</template>
<template v-else>
<el-autocomplete
v-if="col.prop === 'lineName'"
v-model="scope.row[col.prop]"
size="mini"
clearable
:fetch-suggestions="queryLineHistory"
@select="onLineHistorySelect(scope.row)"
@input="onLineHistoryInput(scope.row)"
@keydown.native.enter.prevent="focusNextCell(scope.$index, colIndex, 'down')"
@keydown.native.tab.prevent="focusNextCell(scope.$index, colIndex, 'right')"
/>
<el-input
v-else
v-model="scope.row[col.prop]"
size="mini"
clearable
@input="onCellChange(scope.row)"
@keydown.native.enter.prevent="focusNextCell(scope.$index, colIndex, 'down')"
@keydown.native.tab.prevent="focusNextCell(scope.$index, colIndex, 'right')"
/>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="70" fixed="right" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="clearRow(scope.row)">清空</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager-wrap">
<el-pagination
small
background
layout="prev, pager, next, total"
:current-page.sync="pager.pageNum"
:page-size="pager.pageSize"
:total="totalRows"
/>
</div>
</div>
<div v-if="missingRows.length" class="missing-tip">
<div v-for="msg in missingRows" :key="msg" class="missing-item">{{ msg }}</div>
</div>
<el-drawer
title="列设置"
:visible.sync="columnSettingVisible"
direction="rtl"
size="55%"
append-to-body
@open="resetColumnDraft">
<div class="col-setting-body">
<div class="col-setting-toolbar">
<el-button size="mini" @click="restoreDefaultColumns">恢复默认</el-button>
<el-button size="mini" @click="resetColumnDraft">重置</el-button>
<el-button size="mini" type="primary" @click="saveColumnSettings">保存</el-button>
</div>
<div class="col-section">
<div class="col-section-title">固定列最多5列</div>
<draggable v-model="fixedColumnList" handle=".drag-handle" animation="200" class="col-setting-grid">
<div v-for="item in fixedColumnList" :key="`fixed-${item.prop}`" class="col-setting-item">
<i class="el-icon-rank drag-handle" />
<span class="col-label">{{ item.label }}</span>
<el-button type="text" size="mini" @click="removeFromFixed(item.prop)">移除</el-button>
</div>
</draggable>
</div>
<div class="col-section">
<div class="col-section-title">其他列</div>
<draggable v-model="normalColumnList" handle=".drag-handle" animation="200" class="col-setting-grid">
<div v-for="item in normalColumnList" :key="`normal-${item.prop}`" class="col-setting-item">
<i class="el-icon-rank drag-handle" />
<span class="col-label">{{ item.label }}</span>
<el-button type="text" size="mini" @click="addToFixed(item.prop)">加入固定</el-button>
</div>
</draggable>
</div>
</div>
</el-drawer>
</div>
</template>
<script>
import { fetchQuickSheetList, fetchQuickSheetPreset, saveQuickSheet, exportQuickSheet, deleteQuickSheetRow } from '@/api/aps/quickSheet'
import { listProductionLine } from '@/api/wms/productionLine'
import { getTemplateByKey } from './sheets/templates'
import { saveAs } from 'file-saver'
import draggable from 'vuedraggable'
export default {
name: 'ApsQuickSheet',
components: { draggable },
data() {
return {
loading: false,
saving: false,
rows: [],
lineOptions: [],
lineHistory: [],
dirtyIds: new Set(),
autoSaveTimer: null,
templateKey: 'unified',
filter: {
range: []
},
pager: {
pageNum: 1,
pageSize: 25
},
columnSettingVisible: false,
columnSettingList: [],
fixedColumnList: [],
normalColumnList: []
}
},
computed: {
currentTemplate() {
return getTemplateByKey(this.templateKey)
},
flatColumns() {
const res = []
const loop = (cols) => {
;(cols || []).forEach(c => {
if (c.children && c.children.length) loop(c.children)
else res.push(c)
})
}
loop(this.currentTemplate.columns || [])
return res.filter(c => c && c.prop)
},
displayColumns() {
const setting = this.columnSettingList || []
const hasSetting = setting.length > 0
const base = this.flatColumns
const selected = hasSetting
? setting.filter(i => i.visible).map(i => base.find(c => c.prop === i.prop)).filter(Boolean)
: base
const selectedSet = new Set(selected.map(c => c.prop))
const rest = base.filter(c => !selectedSet.has(c.prop))
return [...selected, ...rest]
},
fixedProps() {
return (this.columnSettingList || []).filter(i => i.visible && i.fixed).map(i => i.prop).slice(0, 5)
},
displayRows() {
const [startAfter, endBefore] = this.filter.range || []
const filtered = this.rows.filter(r => {
if (!r.startTime) return true
const d = String(r.startTime).slice(0, 10)
if (startAfter && d < startAfter) return false
if (endBefore && d > endBefore) return false
return true
})
const start = (this.pager.pageNum - 1) * this.pager.pageSize
const page = filtered.slice(start, start + this.pager.pageSize)
if (page.length < this.pager.pageSize) {
return page.concat(this.buildEmptyRows(this.pager.pageSize - page.length))
}
return page
},
totalRows() {
const [startAfter, endBefore] = this.filter.range || []
return this.rows.filter(r => {
if (!r.startTime) return true
const d = String(r.startTime).slice(0, 10)
if (startAfter && d < startAfter) return false
if (endBefore && d > endBefore) return false
return true
}).length
},
missingRows() {
return this.collectMissing(this.displayRows)
}
},
created() {
const today = new Date()
const start = `${today.getFullYear()}-${`${today.getMonth() + 1}`.padStart(2, '0')}-${`${today.getDate()}`.padStart(2, '0')}`
this.filter.range = [start, '']
this.initColumnSettings()
this.loadRows()
this.loadLines()
window.addEventListener('keydown', this.handleGlobalKeydown)
},
beforeDestroy() {
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer)
this.persistColumnSettings()
window.removeEventListener('keydown', this.handleGlobalKeydown)
},
methods: {
handleGlobalKeydown(e) {
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.userAgent)
const ctrlOrCmd = isMac ? e.metaKey : e.ctrlKey
if (ctrlOrCmd && (e.key === 's' || e.key === 'S')) {
e.preventDefault()
this.saveAll(false)
}
},
focusNextCell(visibleRowIndex, colIndex, direction) {
const pageStart = (this.pager.pageNum - 1) * this.pager.pageSize
const targetRow = direction === 'down' ? visibleRowIndex + 1 : visibleRowIndex
const targetCol = direction === 'right' ? colIndex + 1 : colIndex
if (targetCol >= this.displayColumns.length) return
const globalRowIndex = pageStart + targetRow
if (globalRowIndex >= this.rows.length) {
this.rows.push(this.buildEmptyRow())
}
this.$nextTick(() => {
const wrappers = this.$el.querySelectorAll('.excel-table .el-table__body-wrapper tbody tr')
const tr = wrappers[targetRow]
if (!tr) return
const tds = tr.querySelectorAll('td')
const td = tds[targetCol + 1]
if (!td) return
const input = td.querySelector('input, textarea, .el-input__inner')
if (input) input.focus()
})
},
async loadLines() {
const res = await listProductionLine({ pageNum: 1, pageSize: 1000 })
this.lineOptions = res.rows || []
},
async loadRows() {
this.loading = true
try {
const res = await fetchQuickSheetList()
this.rows = (res.data || []).map(r => this.normalizeRow(r))
this.lineHistory = this.rows
.map(r => r.lineName)
.filter(v => v && v.trim())
.filter((v, i, arr) => arr.indexOf(v) === i)
} finally {
this.loading = false
}
},
buildEmptyRows(count) {
return Array.from({ length: count }).map(() => this.buildEmptyRow())
},
async applyPreset(line) {
if (!line || !line.lineId) return
const res = await fetchQuickSheetPreset({ lineId: line.lineId })
const preset = res.data || []
if (!preset.length) return
const targetRow = this.rows.find(r => !r.lineId && !r.lineName) || this.buildEmptyRow()
const base = this.normalizeRow(preset[0])
Object.keys(base).forEach(k => {
if (k === 'quickSheetId') return
targetRow[k] = base[k]
})
targetRow.lineId = line.lineId
targetRow.lineName = line.lineName || line.lineCode || ''
if (!this.rows.includes(targetRow)) {
this.rows.push(targetRow)
}
if (this.rows.length < 25) {
this.rows = this.rows.concat(this.buildEmptyRows(25 - this.rows.length))
}
this.markDirty(targetRow)
this.scheduleAutoSave()
},
onCellChange(row) {
if (row && !this.rows.includes(row)) {
this.rows.push(row)
}
this.markDirty(row)
this.scheduleAutoSave()
},
queryLineHistory(query, cb) {
const list = this.lineHistory || []
const q = (query || '').trim().toLowerCase()
const res = list
.filter(item => !q || String(item).toLowerCase().includes(q))
.map(item => ({ value: item }))
cb(res)
},
onLineHistoryInput(row) {
if (!row) return
if (row.lineId && row.lineName && row.lineName.trim() !== String(row.lineName).trim()) {
row.lineId = null
}
this.onCellChange(row)
},
onLineHistorySelect(row) {
return () => {
this.onCellChange(row)
}
},
onFilterChange() {
this.pager.pageNum = 1
},
normalizeRow(row) {
const base = this.buildEmptyRow()
Object.keys(base).forEach(k => {
if (row && row[k] !== undefined && row[k] !== null) base[k] = row[k]
})
const idVal = row ? (row.quickSheetId !== undefined && row.quickSheetId !== null ? row.quickSheetId : row.id) : null
base.quickSheetId = idVal != null ? idVal : null
return base
},
buildEmptyRow() {
const row = { quickSheetId: null }
this.flatColumns.forEach(col => {
if (!col.prop) return
row[col.prop] = ''
})
return row
},
isRowEmpty(row) {
if (!row) return true
return !this.flatColumns.some(col => col.prop && row[col.prop])
},
isTimeField(prop) {
return prop === 'startTime' || prop === 'endTime'
},
isLeadingColumn(col) {
if (!col || !col.prop) return false
return this.leadingPropsByRole.includes(col.prop)
},
initColumnSettings() {
const key = this.getColumnSettingKey()
const stored = localStorage.getItem(key)
const base = this.flatColumns.map((col, idx) => ({ prop: col.prop, label: col.label, visible: true, fixed: idx < 5 }))
if (!stored) {
this.columnSettingList = base
this.resetColumnDraft()
return
}
try {
const parsed = JSON.parse(stored)
if (!Array.isArray(parsed)) {
this.columnSettingList = base
this.resetColumnDraft()
return
}
const savedProps = parsed.map(i => i.prop)
const extras = base.filter(i => !savedProps.includes(i.prop))
this.columnSettingList = [...parsed.filter(i => base.some(b => b.prop === i.prop)).map(i => {
const match = base.find(b => b.prop === i.prop)
return { ...match, visible: i.visible !== false, fixed: i.fixed === true }
}), ...extras]
this.resetColumnDraft()
} catch (e) {
this.columnSettingList = base
this.resetColumnDraft()
}
},
resetColumnDraft() {
const fixed = (this.columnSettingList || []).filter(i => i.visible && i.fixed).slice(0, 5)
const normal = (this.columnSettingList || []).filter(i => !i.fixed || !i.visible)
this.fixedColumnList = fixed.map(i => ({ ...i, visible: true, fixed: true }))
this.normalColumnList = normal.map(i => ({ ...i, fixed: false }))
},
saveColumnSettings() {
const fixed = (this.fixedColumnList || []).map(i => ({ ...i, visible: true, fixed: true }))
const normal = (this.normalColumnList || []).map(i => ({ ...i, fixed: false }))
this.columnSettingList = [...fixed, ...normal]
this.persistColumnSettings()
this.columnSettingVisible = false
this.pager.pageNum = 1
this.loadRows().finally(() => {
this.$nextTick(() => {
this.$refs.quickSheetTable && this.$refs.quickSheetTable.doLayout && this.$refs.quickSheetTable.doLayout()
})
})
},
addToFixed(prop) {
if ((this.fixedColumnList || []).length >= 5) {
this.$message.warning('固定列最多5列')
return
}
const idx = this.normalColumnList.findIndex(i => i.prop === prop)
if (idx < 0) return
const item = this.normalColumnList.splice(idx, 1)[0]
this.fixedColumnList.push({ ...item, visible: true, fixed: true })
},
removeFromFixed(prop) {
const idx = this.fixedColumnList.findIndex(i => i.prop === prop)
if (idx < 0) return
const item = this.fixedColumnList.splice(idx, 1)[0]
this.normalColumnList.unshift({ ...item, fixed: false })
},
persistColumnSettings() {
const key = this.getColumnSettingKey()
const payload = (this.columnSettingList || []).map(item => ({
prop: item.prop,
label: item.label,
visible: item.visible !== false,
fixed: item.fixed === true
}))
localStorage.setItem(key, JSON.stringify(payload))
},
restoreDefaultColumns() {
const base = this.flatColumns.map((col, idx) => ({ prop: col.prop, label: col.label, visible: true, fixed: idx < 5 }))
this.columnSettingList = base
this.resetColumnDraft()
},
getColumnSettingKey() {
return `apsQuickSheetColumns_${this.templateKey}`
},
markDirty(row) {
if (!row) return
if (!row._tmpId) row._tmpId = Math.random().toString(36).slice(2)
this.dirtyIds.add(row._tmpId)
},
async clearRow(row) {
if (!row) return
if (!row.quickSheetId) {
this.flatColumns.forEach(col => {
if (!col.prop) return
row[col.prop] = ''
})
row.quickSheetId = null
return
}
await deleteQuickSheetRow(row.quickSheetId)
this.flatColumns.forEach(col => {
if (!col.prop) return
row[col.prop] = ''
})
row.quickSheetId = null
},
scheduleAutoSave() {
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer)
this.autoSaveTimer = setTimeout(() => {
this.saveAll(true)
}, 800)
},
async saveAll(isAuto = false, payload) {
const rows = payload && payload.rows
? payload.rows
: this.rows.filter(r => r && r._tmpId && this.dirtyIds.has(r._tmpId))
.map(r => ({
...r,
quickSheetId: r.quickSheetId || null
}))
if (!rows.length) {
if (!isAuto) this.$message.warning('暂无可保存数据')
return
}
const missing = this.collectMissing(rows)
if (missing.length && !isAuto) {
this.$message.warning(`必填项未填写:${missing.join('')}`)
return
}
this.saving = true
try {
await saveQuickSheet({ rows })
if (!isAuto) this.$message.success('保存成功')
rows.forEach(r => {
if (r._tmpId) this.dirtyIds.delete(r._tmpId)
})
} finally {
this.saving = false
}
},
exportCsv() {
this.loading = true
exportQuickSheet()
.then(blob => {
const filename = `quick_sheet_${new Date().getTime()}.xlsx`
saveAs(new Blob([blob], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename)
})
.finally(() => {
this.loading = false
})
},
addRow() {
this.rows.unshift(this.buildEmptyRow())
this.pager.pageNum = 1
},
collectMissing(rows) {
const msgs = []
rows.forEach((row, idx) => {
if (this.isRowEmpty(row)) return
const line = idx + 1
const missing = []
if (!row.lineName && !row.lineId) missing.push('产线')
if (!row.planCode) missing.push('计划号')
if (!row.startTime) missing.push('开始时间')
if (!row.endTime) missing.push('结束时间')
if (missing.length) {
msgs.push(`${line}行缺少${missing.join('、')}`)
}
})
return msgs
}
}
}
</script>
<style scoped lang="scss">
.aps-quick-sheet {
padding: 8px;
background: #f7f9fc;
}
.sheet-toolbar,
.sheet-body {
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fff;
}
.sheet-toolbar {
margin-bottom: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sheet-actions { display: flex; gap: 8px; }
.preset-bar { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #667085; }
.preset-title { font-weight: 600; color: #475467; }
::v-deep .excel-table {
border: 1px solid #edf1f7;
}
::v-deep .excel-table th.el-table__cell {
background: #fafbfe;
color: #5d6b82;
font-weight: 600;
border-right: 1px solid #edf1f7;
border-bottom: 1px solid #edf1f7;
padding: 4px 0;
}
::v-deep .excel-table td.el-table__cell {
border-right: 1px solid #f0f2f6;
border-bottom: 1px solid #f0f2f6;
padding: 1px 2px;
}
::v-deep .excel-table .el-table__row:hover > td {
background-color: #f7faff !important;
}
::v-deep .el-input__inner {
border-radius: 0;
height: 26px;
line-height: 26px;
border: none;
box-shadow: none;
background: transparent;
}
::v-deep .el-input__inner:focus {
border: none;
box-shadow: inset 0 0 0 1px #4a90e2;
background: #fff;
}
.missing-tip {
margin-top: 8px;
padding: 8px 12px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 6px;
font-size: 12px;
color: #d46b08;
}
.missing-item {
line-height: 1.6;
}
.col-setting-body {
padding: 12px;
height: calc(100% - 12px);
overflow: hidden;
}
.col-setting-toolbar {
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
}
.col-section {
margin-bottom: 12px;
}
.col-section-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.col-setting-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.col-setting-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fff;
margin-bottom: 0;
min-height: 38px;
}
.col-setting-item:hover {
border-color: #d9ecff;
background: #f5faff;
}
.drag-handle {
cursor: move;
color: #909399;
}
</style>