Files
MrleeAdmin/frontend/web/app/apps/produce/pages/data-entry/components/MachineEntryPanel.vue
T

550 lines
21 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="entry-mode-panel">
<div class="main-top-grid">
<section class="workbench-panel entry-column-panel">
<div class="panel-header column-header">
<div>
<div class="panel-title">整机扫码输入</div>
<div class="panel-subtitle">当前工序:{{ processLabel || '未选择' }} / {{ stepLabel || '未选择' }}</div>
</div>
<div class="panel-metric-card">
<span class="panel-metric-label">总数</span>
<strong class="panel-metric-value">{{ hideProcessTotal ? '***' : statistics.today_total }}</strong>
<button type="button" class="metric-eye-button" :class="{ 'is-hidden': hideProcessTotal }" @click="hideProcessTotal = !hideProcessTotal">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5C6.5 5 2.12 8.44 1 12c1.12 3.56 5.5 7 11 7s9.88-3.44 11-7c-1.12-3.56-5.5-7-11-7Zm0 11.5A4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 0 1 0 9Zm0-2.5A2 2 0 1 0 12 10a2 2 0 0 0 0 4Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
<div class="workbench-form">
<div class="field-group field-primary">
<label>加工单号</label>
<el-input
ref="processOrderNoInputRef"
v-model="processOrderNo"
clearable
size="large"
placeholder="请扫描或输入加工单号"
@keyup.enter="handleProcessOrderNoComplete"
/>
</div>
<div class="field-row field-row-split">
<div class="field-group">
<label>商品编码</label>
<el-input
ref="productCodeInputRef"
v-model="productCode"
clearable
size="large"
placeholder="请扫描或输入商品编码"
@keyup.enter="handleProductCodeComplete"
/>
</div>
<div class="field-group field-group-quantity">
<label>数量</label>
<el-input-number
ref="quantityInputRef"
v-model="quantity"
:min="1"
:step="1"
size="large"
controls-position="right"
class="quantity-input"
@keyup.enter="handleQuantityComplete"
/>
</div>
</div>
<div class="field-row">
<div class="field-group">
<div class="field-label-row">
<label>用户编码</label>
<div class="lock-switch">
<span>锁定用户编码</span>
<el-switch v-model="autoUserCode" />
</div>
</div>
<el-input
ref="userCodeInputRef"
v-model="userCode"
:disabled="autoUserCode"
clearable
size="large"
placeholder="请扫描或输入用户编码"
@update:model-value="userCode = String($event || '').toUpperCase()"
@input="handleUserCodeInput"
@keyup.enter="handleUserCodeComplete"
@blur="runUserCodeCheck(userCode, true)"
/>
<div v-if="userCodeCheckError" class="field-error-tip">{{ userCodeCheckError }}</div>
</div>
</div>
<div class="action-row">
<el-button type="primary" size="large" :loading="submitting" @click="submitEntry">提交</el-button>
<el-button size="large" @click="resetForm">重置输入</el-button>
</div>
</div>
</section>
<section class="workbench-panel stats-column-panel">
<div class="dashboard-header column-header">
<div class="dashboard-title">工位统计</div>
<span class="dashboard-caption">当日扫码量</span>
</div>
<div v-if="userStats.length" class="station-grid">
<div v-for="item in userStats" :key="item.user_code" class="station-card">
<div class="station-icon">工</div>
<div class="station-info">
<strong>{{ item.user_code }}</strong>
</div>
<b>{{ item.count }}</b>
</div>
</div>
<el-empty v-else description="暂无数据" :image-size="72" />
</section>
</div>
<section class="records-panel">
<div class="dashboard-header records-header">
<div class="dashboard-title">最近记录</div>
<span class="dashboard-caption">实时刷新最近 10 条</span>
</div>
<el-table :data="recentRecords" stripe>
<el-table-column prop="process_order_no" label="加工单号" min-width="180" />
<el-table-column prop="product_code" label="商品编码" min-width="180" />
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="user_code" label="用户编码" min-width="120" />
<el-table-column label="工序" min-width="140">
<template #default="scope">{{ scope.row.process?.process_name || '-' }}</template>
</el-table-column>
<el-table-column label="步骤" min-width="140">
<template #default="scope">{{ scope.row.step?.step_name || '-' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="120">
<template #default="scope">{{ scope.row.status?.status_name || '-' }}</template>
</el-table-column>
<el-table-column label="结果" width="100">
<template #default="scope">
<el-tag :type="Number(scope.row.scan_result) === 1 ? 'success' : 'danger'">
{{ Number(scope.row.scan_result) === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="120" />
<el-table-column prop="created_date" label="扫码时间" min-width="180" />
</el-table>
</section>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
checkMemberUser,
getDataEntryConfig,
getProductRecentRecords,
getProductStatistics,
getProductUserCodeStats,
submitProductRecord,
type ProduceRecordItem,
type ProduceDataEntrySpecificRule,
type ProduceStatistics,
type ProduceUserStatsItem,
} from '~/apps/produce/api/data-entry'
interface Props {
selectedProcess?: string | number
selectedStep?: string | number
processLabel?: string
stepLabel?: string
scanMode?: 'manual' | 'scanner'
}
const props = withDefaults(defineProps<Props>(), {
selectedProcess: '',
selectedStep: '',
processLabel: '',
stepLabel: '',
scanMode: 'manual',
})
const emit = defineEmits<{
'play-success': []
'play-error': []
'speak-success': []
'speak-error': []
}>()
const STORAGE_KEY_LOCK = 'produce_web_machine_entry_lock'
const STORAGE_KEY_USER_CODE = 'produce_web_machine_entry_user_code'
const STORAGE_KEY_HIDE_PROCESS_TOTAL = 'produce_web_machine_entry_hide_process_total'
const processOrderNo = ref('')
const productCode = ref('')
const quantity = ref(1)
const userCode = ref('')
const autoUserCode = ref(false)
const hideProcessTotal = ref(false)
const submitting = ref(false)
const recentRecords = ref<ProduceRecordItem[]>([])
const userStats = ref<ProduceUserStatsItem[]>([])
const statistics = ref<ProduceStatistics>({ today_total: 0, success_total: 0, failed_total: 0 })
const basicSettings = ref<Record<string, any>>({})
const specificRules = ref<ProduceDataEntrySpecificRule[]>([])
const checkingUserCode = ref(false)
const userCodeCheckError = ref('')
const processOrderNoInputRef = ref()
const productCodeInputRef = ref()
const quantityInputRef = ref()
const userCodeInputRef = ref()
let userCodeCheckTimer: ReturnType<typeof setTimeout> | null = null
let scannerSubmitTimer: ReturnType<typeof setTimeout> | null = null
let statisticsRequestToken = 0
let recentRecordsRequestToken = 0
let dataEntryConfigRequestToken = 0
let initialized = false
const restoreLocalState = () => {
if (typeof window === 'undefined') return
autoUserCode.value = localStorage.getItem(STORAGE_KEY_LOCK) === 'true'
hideProcessTotal.value = localStorage.getItem(STORAGE_KEY_HIDE_PROCESS_TOTAL) === 'true'
if (autoUserCode.value) {
userCode.value = localStorage.getItem(STORAGE_KEY_USER_CODE) || ''
}
}
const saveLocalState = () => {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY_LOCK, String(autoUserCode.value))
localStorage.setItem(STORAGE_KEY_HIDE_PROCESS_TOTAL, String(hideProcessTotal.value))
if (autoUserCode.value && userCode.value.trim()) {
localStorage.setItem(STORAGE_KEY_USER_CODE, userCode.value)
} else {
localStorage.removeItem(STORAGE_KEY_USER_CODE)
}
}
const focusPrimaryInput = () => { setTimeout(() => processOrderNoInputRef.value?.focus?.(), 80) }
const focusProductCodeInput = () => { setTimeout(() => productCodeInputRef.value?.focus?.(), 80) }
const focusQuantityInput = () => { setTimeout(() => quantityInputRef.value?.focus?.(), 80) }
const focusUserCodeInput = () => { setTimeout(() => userCodeInputRef.value?.focus?.(), 80) }
const clearScannerSubmitTimer = () => {
if (!scannerSubmitTimer) return
clearTimeout(scannerSubmitTimer)
scannerSubmitTimer = null
}
const handleProcessOrderNoComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (!submitting.value) focusProductCodeInput()
}
const handleProductCodeComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (!submitting.value) focusQuantityInput()
}
const handleQuantityComplete = () => {
if (props.scanMode !== 'scanner') return
if (submitting.value) return
if (autoUserCode.value) {
void submitEntry()
return
}
focusUserCodeInput()
}
const handleUserCodeComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
return
}
if (submitting.value) return
clearScannerSubmitTimer()
userCodeInputRef.value?.blur?.()
scannerSubmitTimer = setTimeout(() => {
scannerSubmitTimer = null
if (!submitting.value) void submitEntry()
}, 50)
}
const handleUserCodeInput = () => {
if (props.scanMode !== 'scanner' || !userCode.value.trim() || submitting.value) return
clearScannerSubmitTimer()
scannerSubmitTimer = setTimeout(() => {
scannerSubmitTimer = null
handleUserCodeComplete()
}, 300)
}
const resetDashboardData = () => {
recentRecords.value = []
userStats.value = []
statistics.value = { today_total: 0, success_total: 0, failed_total: 0 }
}
const isSameSelection = (processId: string | number, stepId: string | number) => String(props.selectedProcess) === String(processId) && String(props.selectedStep) === String(stepId)
const resetUserCodeCheckState = () => { userCodeCheckError.value = '' }
const runUserCodeCheck = async (value: string, silent: boolean = false) => {
const normalizedValue = value.trim()
if (!normalizedValue) {
checkingUserCode.value = false
resetUserCodeCheckState()
return false
}
checkingUserCode.value = true
try {
await checkMemberUser(normalizedValue)
userCodeCheckError.value = ''
return true
} catch (error: any) {
userCodeCheckError.value = error?.message || '用户编码不存在'
if (!silent) ElMessage.warning(userCodeCheckError.value)
return false
} finally {
checkingUserCode.value = false
}
}
const scheduleUserCodeCheck = (value: string) => {
if (userCodeCheckTimer) clearTimeout(userCodeCheckTimer)
const normalizedValue = value.trim()
if (!normalizedValue) {
resetUserCodeCheckState()
checkingUserCode.value = false
return
}
userCodeCheckTimer = setTimeout(() => { void runUserCodeCheck(normalizedValue, true) }, 360)
}
const loadStatisticsData = async () => {
if (!props.selectedProcess || !props.selectedStep) {
resetDashboardData()
return
}
const requestToken = ++statisticsRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const params = { process_id: processSnapshot, step_id: stepSnapshot }
const [statsRes, userStatsRes] = await Promise.all([
getProductStatistics(params).catch(() => ({ today_total: 0, success_total: 0, failed_total: 0 })),
getProductUserCodeStats(params).catch(() => []),
])
if (requestToken !== statisticsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) return
statistics.value = {
today_total: Number(statsRes?.today_total || 0),
success_total: Number(statsRes?.success_total || 0),
failed_total: Number(statsRes?.failed_total || 0),
}
userStats.value = Array.isArray(userStatsRes) ? userStatsRes : []
}
const loadRecentData = async () => {
if (!props.selectedProcess || !props.selectedStep) {
recentRecords.value = []
return
}
const requestToken = ++recentRecordsRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const res = await getProductRecentRecords({ process_id: processSnapshot, step_id: stepSnapshot, limit: 10 }).catch(() => ({ items: [] }))
if (requestToken !== recentRecordsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) return
recentRecords.value = Array.isArray(res?.items) ? res.items : []
}
const loadDataEntryConfig = async () => {
const requestToken = ++dataEntryConfigRequestToken
const processSnapshot = props.selectedProcess
const stepSnapshot = props.selectedStep
const processOrderNoSnapshot = processOrderNo.value.trim()
const productCodeSnapshot = productCode.value.trim()
const userCodeSnapshot = userCode.value.trim()
const res = await getDataEntryConfig({
process_id: processSnapshot,
step_id: stepSnapshot,
apply_mode: 'machine',
process_order_no: processOrderNoSnapshot,
product_code: productCodeSnapshot,
user_code: userCodeSnapshot,
order_no: processOrderNoSnapshot,
}).catch(() => null)
if (requestToken !== dataEntryConfigRequestToken || !isSameSelection(processSnapshot, stepSnapshot) || processOrderNo.value.trim() !== processOrderNoSnapshot || productCode.value.trim() !== productCodeSnapshot || userCode.value.trim() !== userCodeSnapshot) return
basicSettings.value = res?.basic_setting || {}
specificRules.value = Array.isArray(res?.specific_rules) ? res.specific_rules : []
}
const matchSpecificRule = (rule: ProduceDataEntrySpecificRule, valueMap: Record<string, string>) => {
const matchField = rule.match_field || 'order_no'
const matchValue = String(valueMap[matchField] || '').trim()
const pattern = String(rule.rule_pattern || '').trim()
if (!matchValue || !pattern) {
return false
}
try {
return new RegExp(pattern).test(matchValue)
} catch {
return false
}
}
const validateSpecificRules = () => {
if (!specificRules.value.length) {
return true
}
const context = {
process_order_no: processOrderNo.value.trim(),
product_code: productCode.value.trim(),
user_code: userCode.value.trim(),
}
const blockedRule = specificRules.value.find((rule: ProduceDataEntrySpecificRule) => {
if ((rule.apply_mode || 'order') !== 'machine' && rule.apply_mode !== 'both') {
return false
}
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
})
if (blockedRule) {
ElMessage.warning(blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交')
const matchField = blockedRule.match_field || 'process_order_no'
if (matchField === 'product_code') {
focusProductCodeInput()
} else if (matchField === 'user_code') {
focusUserCodeInput()
} else {
focusPrimaryInput()
}
return false
}
return true
}
const validateBeforeSubmit = () => {
if (!props.selectedProcess) { ElMessage.warning('请选择工序'); return false }
if (!props.selectedStep) { ElMessage.warning('请选择步骤'); return false }
if (!processOrderNo.value.trim()) { ElMessage.warning('请输入加工单号'); focusPrimaryInput(); return false }
const processOrderNoRegex = String(basicSettings.value.process_order_no_regex || '').trim()
if (processOrderNoRegex) {
try {
const regex = new RegExp(processOrderNoRegex)
if (!regex.test(processOrderNo.value.trim())) { ElMessage.warning('加工单号格式不符合生产配置规则'); focusPrimaryInput(); return false }
} catch {}
}
if (!productCode.value.trim()) { ElMessage.warning('请输入商品编码'); focusProductCodeInput(); return false }
const productCodeMinLength = Number(basicSettings.value.product_code_min_length || 0)
if (productCodeMinLength > 0 && productCode.value.trim().length < productCodeMinLength) { ElMessage.warning(`商品编码长度不能少于 ${productCodeMinLength} 位`); focusProductCodeInput(); return false }
const productCodeMaxLength = Number(basicSettings.value.product_code_max_length || 0)
if (productCodeMaxLength > 0 && productCode.value.trim().length > productCodeMaxLength) { ElMessage.warning(`商品编码长度不能超过 ${productCodeMaxLength} 位`); focusProductCodeInput(); return false }
const productCodeRegex = String(basicSettings.value.product_code_regex || '').trim()
if (productCodeRegex) {
try {
const regex = new RegExp(productCodeRegex)
if (!regex.test(productCode.value.trim())) { ElMessage.warning('商品编码格式不符合生产配置规则'); focusProductCodeInput(); return false }
} catch {}
}
if (!Number.isFinite(Number(quantity.value)) || Number(quantity.value) < 1) { ElMessage.warning('数量必须大于 0'); focusQuantityInput(); return false }
if (!userCode.value.trim()) { ElMessage.warning('请输入用户编码'); focusUserCodeInput(); return false }
const userCodeRegex = String(basicSettings.value.user_code_regex || '').trim()
if (userCodeRegex) {
try {
const regex = new RegExp(userCodeRegex)
if (!regex.test(userCode.value.trim())) { ElMessage.warning('用户编码格式不符合基础设置规则'); focusUserCodeInput(); return false }
} catch {}
}
if (userCodeCheckError.value) { ElMessage.warning(userCodeCheckError.value); focusUserCodeInput(); return false }
if (!validateSpecificRules()) { return false }
return true
}
const resetForm = () => {
processOrderNo.value = ''
productCode.value = ''
quantity.value = 1
if (!autoUserCode.value) {
userCode.value = ''
resetUserCodeCheckState()
}
saveLocalState()
focusPrimaryInput()
}
const submitEntry = async () => {
if (!validateBeforeSubmit()) return
const passed = await runUserCodeCheck(userCode.value)
if (!passed) { emit('play-error'); nextTick(() => focusUserCodeInput()); return }
submitting.value = true
try {
const result = await submitProductRecord({
process_order_no: processOrderNo.value.trim(),
product_code: productCode.value.trim(),
quantity: Number(quantity.value) || 1,
process_id: props.selectedProcess,
step_id: props.selectedStep,
user_code: userCode.value.trim(),
scan_result: 1,
})
if (Number(result?.code) !== 0) throw new Error(result?.msg || '录入失败')
ElMessage.success('录入成功')
emit('play-success')
emit('speak-success')
saveLocalState()
resetForm()
await Promise.all([loadStatisticsData(), loadRecentData()])
} catch (error: any) {
ElMessage.error(error?.message || '录入失败')
emit('play-error')
emit('speak-error')
} finally {
submitting.value = false
}
}
const refreshBySelection = async () => {
if (!props.selectedProcess || !props.selectedStep) {
basicSettings.value = {}
specificRules.value = []
resetDashboardData()
return
}
resetDashboardData()
await Promise.all([loadStatisticsData(), loadRecentData(), loadDataEntryConfig()])
focusPrimaryInput()
}
watch(() => [props.selectedProcess, props.selectedStep], () => { if (initialized) void refreshBySelection() })
watch(processOrderNo, () => { if (initialized) { saveLocalState(); void loadDataEntryConfig() } })
watch([productCode, quantity], () => { if (initialized) { saveLocalState(); void loadDataEntryConfig() } })
watch(userCode, () => { if (initialized) { scheduleUserCodeCheck(userCode.value); if (autoUserCode.value || userCode.value.trim()) saveLocalState(); void loadDataEntryConfig() } })
watch([autoUserCode, hideProcessTotal], () => { if (initialized) saveLocalState() })
onMounted(async () => {
restoreLocalState()
await refreshBySelection()
initialized = true
})
onBeforeUnmount(() => {
if (userCodeCheckTimer) clearTimeout(userCodeCheckTimer)
clearScannerSubmitTimer()
})
defineExpose({ focusPrimaryInput, focusProductCodeInput, focusQuantityInput, focusUserCodeInput, submitEntry, resetForm })
</script>
<style scoped src="./entry-panel.css"></style>