完成排产(测试过了)

This commit is contained in:
2026-03-08 16:02:44 +08:00
parent b660ddcc3e
commit 7736ac3311
125 changed files with 10418 additions and 15 deletions

View File

@@ -0,0 +1,512 @@
<template>
<div :class="embedded ? 'factory-calendar-board embedded' : 'factory-calendar-board app-container'">
<div class="calendar-shell">
<div class="calendar-head">
<div>
<div class="calendar-title">工厂日历排产看板</div>
<div class="calendar-sub">同一日期聚合展示全部产线×班次悬浮查看完整任务信息支持快速编辑日历</div>
</div>
<div class="head-actions">
<el-button size="mini" @click="shiftRangeDays(-pager.pageSize)">上一段</el-button>
<el-button size="mini" @click="shiftRangeDays(pager.pageSize)">下一段</el-button>
<el-button size="mini" @click="setHistoryRange">近30天</el-button>
<el-button size="mini" @click="setHybridRange">前后30天</el-button>
<el-button size="mini" type="primary" plain @click="goToday">回到今天</el-button>
<el-date-picker
v-model="jumpDate"
size="mini"
type="date"
value-format="yyyy-MM-dd"
placeholder="跳转日期"
style="width: 140px"
@change="jumpToDate"
/>
<el-select v-model="pager.pageSize" size="mini" style="width: 110px" @change="pager.pageNum = 1">
<el-option :value="7" label="每页7天" />
<el-option :value="14" label="每页14天" />
<el-option :value="28" label="每页28天" />
</el-select>
<el-button size="mini" icon="el-icon-refresh" @click="handleQuery">刷新</el-button>
</div>
</div>
<el-form v-if="!embedded" :inline="true" size="small" class="calendar-filter">
<el-form-item label="日期范围">
<el-date-picker
v-model="query.range"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
style="width: 260px"
/>
</el-form-item>
<el-form-item label="产线">
<el-select v-model="query.lineId" clearable filterable placeholder="全部产线" style="width: 220px">
<el-option
v-for="line in lineOptions"
:key="line.lineId"
:label="line.lineName || line.lineCode || ('产线' + line.lineId)"
:value="line.lineId"
/>
</el-select>
</el-form-item>
<el-form-item label="订单/产品">
<el-input v-model="query.keyword" clearable placeholder="订单号、产品名" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh-left" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="legend-wrap">
<span class="legend-title">日历图例</span>
<span class="legend-item"><i class="legend-dot day-work" />工作日</span>
<span class="legend-item"><i class="legend-dot day-rest" />休息日</span>
</div>
<div class="calendar-body" :style="boardStyle" v-loading="loading">
<el-empty v-if="!lineBoard.length || !pagedDateList.length || !shifts.length" description="暂无产线或班次基础数据" />
<div v-else class="date-grid" :style="dateGridStyle">
<div class="date-col" v-for="date in displayDateList" :key="date">
<div class="date-head">{{ date }}</div>
<div class="slot-list">
<el-tooltip
v-for="slot in getDateSlots(date)"
:key="date + '-' + slot.lineId + '-' + slot.shiftId"
placement="top"
effect="dark"
>
<div slot="content" class="slot-tooltip">
<div>产线{{ slot.lineName }}</div>
<div>班次{{ slot.shiftName }}{{ slot.startTime }}-{{ slot.endTime }}<span v-if="slot.crossDay === 1">+1</span></div>
<div>状态{{ workdayLabel(slot.lineId, date, slot.shiftId) }}</div>
<div v-if="slot.tasks.length">任务{{ slot.tasks.map(i => taskTooltipText(i)).join('') }}</div>
<div v-else>任务</div>
</div>
<div class="slot-row" :class="workdayClass(slot.lineId, date, slot.shiftId)">
<div class="slot-left">
<span class="slot-line">{{ shortText(slot.lineName, 6) }}</span>
<span class="slot-shift">{{ shortText(slot.shiftName, 4) }}</span>
</div>
<div class="slot-mid">
<span v-if="slot.tasks.length" class="task-chip" :style="{ backgroundColor: getTaskColor(slot.tasks[0]), borderColor: getTaskColor(slot.tasks[0]) }">
{{ shortText(taskDisplayName(slot.tasks[0]), 14) }}
</span>
<span v-else class="no-task">无任务</span>
<span v-if="slot.tasks.length > 1" class="task-count">+{{ slot.tasks.length - 1 }}</span>
</div>
<el-button type="text" size="mini" class="slot-edit" @click.stop="openShiftEditor(slot, date)">编辑</el-button>
</div>
</el-tooltip>
</div>
</div>
</div>
</div>
<div class="pager-wrap" v-if="dateList.length > pager.pageSize">
<el-pagination
small
background
layout="prev, pager, next, total"
:current-page.sync="pager.pageNum"
:page-size="pager.pageSize"
:total="dateList.length"
/>
</div>
</div>
<el-dialog title="编辑工厂日历班次" :visible.sync="shiftEditor.visible" width="500px" append-to-body>
<el-form ref="shiftEditorFormRef" :model="shiftEditor.form" :rules="shiftEditorRules" label-width="100px" size="small">
<el-form-item label="日期"><el-input :value="shiftEditor.meta.date" readonly /></el-form-item>
<el-form-item label="产线"><el-input :value="shiftEditor.meta.lineName" readonly /></el-form-item>
<el-form-item label="班次"><el-input :value="shiftEditor.meta.shiftName" readonly /></el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="shiftEditor.form.status">
<el-radio :label="1">工作日</el-radio>
<el-radio :label="0">停用/休息</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="计划工时" prop="plannedHours">
<el-input-number v-model="shiftEditor.form.plannedHours" :min="0" :max="24" :step="0.5" :precision="1" style="width: 100%" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="shiftEditor.form.remark" type="textarea" :rows="3" /></el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="shiftEditor.visible = false"> </el-button>
<el-button size="small" type="primary" :loading="shiftEditor.saving" @click="submitShiftEditor"> </el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { fetchFactoryCalendar, listShiftTemplate, addCalendarShift, updateCalendarShift } from '@/api/aps/aps'
import { listProductionLine } from '@/api/wms/productionLine'
const TASK_COLORS = ['#5B8FF9', '#61DDAA', '#65789B', '#F6BD16', '#7262FD', '#78D3F8', '#9661BC', '#F6903D', '#008685', '#F08BB4']
export default {
name: 'ApsFactoryCalendarBoard',
props: {
embedded: { type: Boolean, default: false },
height: { type: Number, default: 420 },
externalQuery: { type: Object, default: () => ({}) }
},
data() {
return {
loading: false,
query: { range: [], lineId: undefined, keyword: '', orderId: undefined },
jumpDate: '',
pager: { pageNum: 1, pageSize: 7 },
lineOptions: [],
shifts: [],
calendarPayload: {
overview: {},
dateList: [],
lineNameMap: {},
shiftNameMap: {},
lineDayMap: {},
lineShiftDayMap: {}
},
shiftEditor: {
visible: false,
saving: false,
meta: { configId: null, date: '', lineId: null, lineName: '', shiftId: null, shiftName: '' },
form: { status: 1, plannedHours: 8, remark: '' }
},
shiftEditorRules: {
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
plannedHours: [{ required: true, message: '请填写计划工时', trigger: 'blur' }]
}
}
},
computed: {
boardStyle() { return { minHeight: `${this.height}px` } },
dateGridStyle() {
const count = this.displayDateList.length || 1
return { gridTemplateColumns: `repeat(${count}, minmax(260px, 1fr))` }
},
lineBoard() {
const map = this.calendarPayload.lineNameMap || {}
let rows = Object.keys(map).map(id => ({ lineId: String(id), lineName: map[id] || ('产线' + id) }))
if (this.query.lineId) rows = rows.filter(i => String(i.lineId) === String(this.query.lineId))
return rows
},
dateList() {
if (!this.query.range || this.query.range.length !== 2) return []
const start = new Date(`${this.query.range[0]} 00:00:00`)
const end = new Date(`${this.query.range[1]} 00:00:00`)
const arr = []
const cursor = new Date(start.getTime())
while (cursor.getTime() <= end.getTime()) {
arr.push(this.formatDate(cursor))
cursor.setDate(cursor.getDate() + 1)
}
return arr
},
pagedDateList() {
const start = (this.pager.pageNum - 1) * this.pager.pageSize
return this.dateList.slice(start, start + this.pager.pageSize)
},
displayDateList() {
return this.pagedDateList || []
},
taskLegend() {
return []
}
},
watch: {
dateList() {
const maxPage = Math.max(1, Math.ceil(this.dateList.length / this.pager.pageSize))
if (this.pager.pageNum > maxPage) this.pager.pageNum = maxPage
},
externalQuery: {
deep: true,
immediate: true,
handler(value) {
if (!value || Object.keys(value).length === 0) return
if (value.range && value.range.length === 2) this.query.range = [value.range[0], value.range[1]]
this.query.lineId = value.lineId || undefined
this.query.orderId = value.orderId || undefined
this.query.keyword = value.keyword || ''
if (this.shifts.length) this.handleQuery()
}
}
},
created() {
if (!this.query.range.length) this.initDefaultRange()
this.loadLines()
this.loadShifts().then(() => this.handleQuery())
},
methods: {
initDefaultRange() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 30)
this.query.range = [this.formatDate(start), this.formatDate(end)]
},
parseDate(dateText) {
return new Date(`${dateText} 00:00:00`)
},
shiftRangeDays(delta) {
if (!this.query.range || this.query.range.length !== 2) {
this.initDefaultRange()
}
const start = this.parseDate(this.query.range[0])
const end = this.parseDate(this.query.range[1])
start.setDate(start.getDate() + delta)
end.setDate(end.getDate() + delta)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
setHistoryRange() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
setHybridRange() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 30)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
goToday() {
const now = new Date()
const days = this.pager.pageSize
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - Math.floor(days / 2))
const end = new Date(start.getTime())
end.setDate(end.getDate() + days - 1)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
jumpToDate(value) {
if (!value) return
const d = this.parseDate(value)
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate() - Math.floor(this.pager.pageSize / 2))
const end = new Date(start.getTime())
end.setDate(end.getDate() + this.pager.pageSize - 1)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
formatDate(d) {
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
},
shortText(text, maxLen) {
const s = String(text || '')
return s.length > maxLen ? `${s.slice(0, maxLen)}` : s
},
shortTime(dt) {
if (!dt) return '--:--'
const d = new Date(dt)
if (Number.isNaN(d.getTime())) return '--:--'
return `${`${d.getHours()}`.padStart(2, '0')}:${`${d.getMinutes()}`.padStart(2, '0')}`
},
taskDisplayName(task) {
return task && task.label ? task.label : '任务'
},
taskTimeLabel(task) {
return task && task.label ? task.label : '--'
},
taskTooltipText(task) {
return task && task.title ? task.title : (task && task.label ? task.label : '--')
},
async loadLines() {
const res = await listProductionLine({ pageNum: 1, pageSize: 1000 })
this.lineOptions = res.rows || []
},
async loadShifts() {
const res = await listShiftTemplate({ pageNum: 1, pageSize: 1000 })
this.shifts = (res.rows || []).sort((a, b) => String(a.startTime).localeCompare(String(b.startTime)))
},
async handleQuery() {
if (!this.query.range || this.query.range.length !== 2) {
this.$message.warning('请先选择日期范围')
return
}
this.loading = true
try {
const params = {
queryStart: `${this.query.range[0]} 00:00:00`,
queryEnd: `${this.query.range[1]} 23:59:59`,
lineId: this.query.lineId || undefined,
orderId: this.query.orderId || undefined
}
const aggRes = await fetchFactoryCalendar(params)
this.calendarPayload = aggRes.data || {
overview: {}, dateList: [], lineNameMap: {}, shiftNameMap: {}, lineDayMap: {}, lineShiftDayMap: {}
}
} finally {
this.loading = false
}
},
resetQuery() {
this.query.lineId = undefined
this.query.orderId = undefined
this.query.keyword = ''
this.pager.pageNum = 1
this.initDefaultRange()
this.handleQuery()
},
getDateSlots(date) {
const slots = []
this.lineBoard.forEach(line => {
this.shifts.forEach(shift => {
const day = this.getLineShiftDay(line.lineId, shift.shiftId, date)
slots.push({
...line,
shiftId: shift.shiftId,
shiftName: shift.shiftName,
startTime: shift.startTime,
endTime: shift.endTime,
crossDay: shift.crossDay,
tasks: day.tasks || [],
taskCount: day.taskCount || 0,
dayStatus: day.dayStatus
})
})
})
return slots
},
workdayLabel(lineId, date, shiftId) {
const day = this.getLineShiftDay(lineId, shiftId, date)
if (day && day.dayStatus !== undefined && day.dayStatus !== null) {
return Number(day.dayStatus) === 1 ? '工作日' : '休息'
}
return '默认'
},
workdayClass(lineId, date, shiftId) {
const day = this.getLineShiftDay(lineId, shiftId, date)
if (day && day.dayStatus !== undefined && day.dayStatus !== null) {
return Number(day.dayStatus) === 1 ? 'workday' : 'restday'
}
return 'neutral'
},
openShiftEditor(slot, date) {
const day = this.getLineShiftDay(slot.lineId, slot.shiftId, date)
this.shiftEditor.meta = {
configId: day && day.configId ? day.configId : null,
date,
lineId: slot.lineId,
lineName: slot.lineName,
shiftId: slot.shiftId,
shiftName: slot.shiftName
}
this.shiftEditor.form = {
status: day && day.dayStatus !== undefined && day.dayStatus !== null ? Number(day.dayStatus) : 1,
plannedHours: day && day.plannedHours !== undefined && day.plannedHours !== null ? Number(day.plannedHours) : 8,
remark: day && day.remark ? day.remark : ''
}
this.shiftEditor.visible = true
this.$nextTick(() => {
this.$refs.shiftEditorFormRef && this.$refs.shiftEditorFormRef.clearValidate()
})
},
submitShiftEditor() {
this.$refs.shiftEditorFormRef.validate(valid => {
if (!valid) return
const payload = {
configId: this.shiftEditor.meta.configId || undefined,
calendarDate: this.shiftEditor.meta.date,
lineId: this.shiftEditor.meta.lineId,
shiftId: this.shiftEditor.meta.shiftId,
status: this.shiftEditor.form.status,
plannedHours: this.shiftEditor.form.plannedHours,
remark: this.shiftEditor.form.remark
}
const req = payload.configId ? updateCalendarShift(payload) : addCalendarShift(payload)
this.shiftEditor.saving = true
req.then(() => {
this.$message.success(payload.configId ? '日历班次已更新' : '日历班次已新增')
this.shiftEditor.visible = false
this.handleQuery()
}).finally(() => {
this.shiftEditor.saving = false
})
})
},
getLineShiftDay(lineId, shiftId, date) {
const map = this.calendarPayload.lineShiftDayMap || {}
return map[`${String(lineId)}|${String(shiftId)}|${date}`] || { taskCount: 0, dayStatus: null, tasks: [] }
},
resolveShiftRange(dateText, shift) {
if (!shift || !shift.startTime || !shift.endTime || !dateText) return null
const baseDate = new Date(`${dateText} 00:00:00`)
const [sh, sm, ss] = shift.startTime.split(':').map(v => Number(v || 0))
const [eh, em, es] = shift.endTime.split(':').map(v => Number(v || 0))
const start = new Date(baseDate.getTime())
start.setHours(sh, sm, ss, 0)
const end = new Date(baseDate.getTime())
end.setHours(eh, em, es, 0)
if (shift.crossDay === 1 || end.getTime() < start.getTime()) end.setDate(end.getDate() + 1)
return { start: start.getTime(), end: end.getTime() }
},
taskKey(task) {
return `${task.orderId || task.orderCode || ''}_${task.processId || task.processName || ''}`
},
getTaskColor(task) {
const key = this.taskKey(task)
let hash = 0
for (let i = 0; i < key.length; i++) hash = ((hash << 5) - hash) + key.charCodeAt(i)
const idx = Math.abs(hash) % TASK_COLORS.length
return TASK_COLORS[idx]
}
}
}
</script>
<style lang="scss" scoped>
.factory-calendar-board {
.calendar-shell { border: 1px solid #eef2f7; border-radius: 8px; background: #fff; padding: 10px; }
.calendar-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.calendar-title { font-size: 16px; font-weight: 600; }
.calendar-sub { margin-top: 4px; color: #909399; font-size: 12px; }
.head-actions { display: flex; gap: 8px; align-items: center; }
.calendar-filter { margin-top: 8px; margin-bottom: 6px; }
.legend-wrap { margin-bottom: 8px; font-size: 12px; color: #606266; }
.legend-title { color: #909399; margin-right: 6px; }
.legend-item { margin-right: 10px; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.legend-dot.day-work { background: #67c23a; }
.legend-dot.day-rest { background: #f56c6c; }
.date-grid { display: grid; gap: 10px; overflow-x: auto; }
.date-col { border: 1px solid #f1f4f8; border-radius: 6px; padding: 8px; background: #fff; }
.date-head { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #409eff; }
.slot-list { display: flex; flex-direction: column; gap: 6px; }
.slot-row { border: 1px solid #f2f5f9; border-radius: 4px; background: #fcfdff; padding: 4px 6px; display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.slot-row.workday { border-left: 3px solid #67c23a; background: #edf8ee; }
.slot-row.restday { border-left: 3px solid #f56c6c; background: #fff2e6; }
.slot-row.neutral { border-left: 3px solid #dcdfe6; }
.slot-left { min-width: 82px; font-size: 12px; color: #606266; display: flex; flex-direction: column; }
.slot-line { font-weight: 600; color: #303133; }
.slot-shift { color: #909399; }
.slot-mid { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px; }
.task-chip { color: #fff; border: 1px solid transparent; border-radius: 10px; padding: 0 8px; font-size: 12px; line-height: 20px; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-count { color: #909399; font-size: 12px; }
.no-task { color: #c0c4cc; font-size: 12px; }
.slot-edit { padding: 0; font-size: 12px; }
.pager-wrap { margin-top: 10px; display: flex; justify-content: flex-end; }
}
.slot-tooltip {
font-size: 12px;
line-height: 1.6;
max-width: 360px;
}
.embedded { padding: 0; }
</style>