整合前端仓库并保留提交历史

This commit is contained in:
2026-08-31 11:52:41 +08:00
172 changed files with 120216 additions and 1 deletions
@@ -0,0 +1,549 @@
<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>
@@ -0,0 +1,610 @@
<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="orderNoInputRef"
v-model="orderNo"
clearable
size="large"
placeholder="请扫描或输入订单号"
@keyup.enter="handleOrderNoComplete"
/>
</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="order_no" label="订单号" min-width="180" />
<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="scan_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,
getRecentRecords,
getStatistics,
getUserCodeStats,
submitScanData,
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_order_entry_lock'
const STORAGE_KEY_USER_CODE = 'produce_web_order_entry_user_code'
const STORAGE_KEY_HIDE_PROCESS_TOTAL = 'produce_web_order_entry_hide_process_total'
const orderNo = ref('')
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 orderNoInputRef = 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(() => orderNoInputRef.value?.focus?.(), 80)
}
const focusUserCodeInput = () => {
setTimeout(() => userCodeInputRef.value?.focus?.(), 80)
}
const clearScannerSubmitTimer = () => {
if (!scannerSubmitTimer) {
return
}
clearTimeout(scannerSubmitTimer)
scannerSubmitTimer = null
}
const handleOrderNoComplete = () => {
if (props.scanMode !== 'scanner') {
void submitEntry()
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) => {
return 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([
getStatistics(params).catch(() => ({ today_total: 0, success_total: 0, failed_total: 0 })),
getUserCodeStats(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 getRecentRecords({
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 orderSnapshot = orderNo.value.trim()
const res = await getDataEntryConfig({
process_id: processSnapshot,
step_id: stepSnapshot,
apply_mode: 'order',
order_no: orderSnapshot,
}).catch(() => null)
if (
requestToken !== dataEntryConfigRequestToken
|| !isSameSelection(processSnapshot, stepSnapshot)
|| orderNo.value.trim() !== orderSnapshot
) {
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 = {
order_no: orderNo.value.trim(),
user_code: userCode.value.trim(),
}
const blockedRule = specificRules.value.find((rule: ProduceDataEntrySpecificRule) => {
if ((rule.apply_mode || 'order') !== 'order' && rule.apply_mode !== 'both') {
return false
}
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
})
if (blockedRule) {
ElMessage.warning(blockedRule.rule_name || '当前订单命中特定规则,禁止提交')
focusPrimaryInput()
return false
}
return true
}
const validateBeforeSubmit = () => {
if (!props.selectedProcess) {
ElMessage.warning('请选择工序')
return false
}
if (!props.selectedStep) {
ElMessage.warning('请选择步骤')
return false
}
if (!orderNo.value.trim()) {
ElMessage.warning('请输入订单号')
focusPrimaryInput()
return false
}
const orderNoMinLength = Number(basicSettings.value.order_no_min_length || 0)
if (orderNoMinLength > 0 && orderNo.value.trim().length < orderNoMinLength) {
ElMessage.warning(`订单号长度不能少于 ${orderNoMinLength} 位`)
focusPrimaryInput()
return false
}
const orderNoRegex = String(basicSettings.value.order_no_regex || '').trim()
if (orderNoRegex) {
try {
const regex = new RegExp(orderNoRegex)
if (!regex.test(orderNo.value.trim())) {
ElMessage.warning('订单号格式不符合基础设置规则')
focusPrimaryInput()
return false
}
} catch {
}
}
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 = () => {
orderNo.value = ''
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 submitScanData({
order_no: orderNo.value.trim(),
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) return
void refreshBySelection()
}
)
watch(orderNo, () => {
if (!initialized) return
saveLocalState()
void loadDataEntryConfig()
})
watch(userCode, () => {
if (!initialized) return
scheduleUserCodeCheck(userCode.value)
if (autoUserCode.value || userCode.value.trim()) {
saveLocalState()
}
})
watch([autoUserCode, hideProcessTotal], () => {
if (!initialized) return
saveLocalState()
})
onMounted(async () => {
restoreLocalState()
await refreshBySelection()
initialized = true
})
onBeforeUnmount(() => {
if (userCodeCheckTimer) {
clearTimeout(userCodeCheckTimer)
}
clearScannerSubmitTimer()
})
defineExpose({
focusPrimaryInput,
focusUserCodeInput,
submitEntry,
resetForm,
})
</script>
<style scoped src="./entry-panel.css"></style>
@@ -0,0 +1,340 @@
.main-top-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 1fr);
gap: 20px;
align-items: stretch;
}
.workbench-panel,
.records-panel {
background: #ffffff;
border-radius: 20px;
border: 1px solid #e5e7eb;
padding: 28px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.column-header {
padding-bottom: 18px;
border-bottom: 1px solid #eef2f7;
margin-bottom: 22px;
}
.panel-metric-card {
min-width: 240px;
min-height: 56px;
padding: 12px 16px;
border-radius: 16px;
background: #f8fbff;
border: 1px solid #e3ebf7;
display: flex;
align-items: center;
gap: 12px;
}
.panel-metric-label {
font-size: 13px;
color: #64748b;
}
.panel-metric-value {
font-size: 28px;
line-height: 1;
font-weight: 700;
color: #2563eb;
margin-left: auto;
}
.metric-eye-button {
width: 32px;
height: 32px;
border: 0;
border-radius: 999px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s ease;
}
.metric-eye-button svg {
width: 18px;
height: 18px;
}
.metric-eye-button.is-hidden {
color: #c0cad8;
}
.metric-eye-button:hover {
background: rgba(37, 99, 235, 0.08);
color: #2563eb;
}
.panel-title {
font-size: 18px;
font-weight: 600;
color: #2563eb;
}
.panel-subtitle {
margin-top: 8px;
font-size: 14px;
color: #6b7280;
}
.workbench-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.field-row {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.field-row-split {
grid-template-columns: minmax(0, 1fr) 180px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.field-group-quantity {
min-width: 0;
}
.field-group label {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.field-error-tip {
font-size: 12px;
color: #ef4444;
margin-top: 6px;
}
.field-primary :deep(.el-input__wrapper),
.field-row :deep(.el-input__wrapper),
.field-row :deep(.el-input-number .el-input__wrapper) {
min-height: 58px;
border-radius: 14px;
box-shadow: 0 0 0 1px #dbe4f0 inset;
}
.field-primary :deep(.el-input__inner),
.field-row :deep(.el-input__inner),
.field-row :deep(.el-input-number .el-input__inner) {
font-size: 18px;
}
.field-primary :deep(.el-input__wrapper.is-focus),
.field-row :deep(.el-input__wrapper.is-focus),
.field-row :deep(.el-input-number .el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
.quantity-input {
width: 100%;
}
.quantity-input :deep(.el-input__wrapper) {
padding-right: 40px;
overflow: hidden;
}
.quantity-input :deep(.el-input-number__increase),
.quantity-input :deep(.el-input-number__decrease) {
right: 1px;
width: 36px;
margin: 0;
background: #f8fafc;
color: #94a3b8;
border-left: 1px solid #dbe4f0;
box-shadow: none;
}
.quantity-input :deep(.el-input-number__increase) {
top: 1px;
height: calc(50% - 0.5px);
border-top-right-radius: 13px;
border-bottom: 1px solid #e5edf6;
}
.quantity-input :deep(.el-input-number__decrease) {
bottom: 1px;
height: calc(50% - 0.5px);
border-bottom-right-radius: 13px;
}
.quantity-input :deep(.el-input-number__increase:hover),
.quantity-input :deep(.el-input-number__decrease:hover) {
color: var(--el-color-primary);
background: #f1f5f9;
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.lock-switch {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #64748b;
}
.action-row {
display: flex;
gap: 12px;
}
.action-row :deep(.el-button) {
min-width: 160px;
border-radius: 12px;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dashboard-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.dashboard-caption {
font-size: 13px;
color: #94a3b8;
}
.station-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.station-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
background: #ffffff;
border: 1px solid #e6edf6;
}
.station-icon {
width: 34px;
height: 34px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #e8f1ff;
color: #3b82f6;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.station-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.station-info strong {
font-size: 16px;
color: #1f2937;
}
.station-card b {
margin-left: auto;
font-size: 32px;
line-height: 1;
color: #2563eb;
}
.records-header {
margin-bottom: 20px;
}
.records-panel :deep(.el-table) {
--el-table-border-color: #eef2f7;
--el-table-header-bg-color: #f8fafc;
}
.records-panel :deep(.el-table th.el-table__cell) {
font-weight: 600;
color: #475569;
}
.records-panel :deep(.el-table td.el-table__cell) {
padding-top: 14px;
padding-bottom: 14px;
}
@media (max-width: 1024px) {
.panel-header,
.field-label-row,
.dashboard-header {
flex-direction: column;
align-items: flex-start;
}
.panel-metric-card {
width: 100%;
min-width: 0;
}
.main-top-grid,
.station-grid {
grid-template-columns: 1fr;
}
.field-row-split {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.workbench-panel,
.records-panel {
padding: 18px;
border-radius: 16px;
}
.action-row {
flex-wrap: wrap;
}
.action-row :deep(.el-button) {
width: 100%;
}
}