整合前端仓库并保留提交历史
This commit is contained in:
@@ -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%;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="produce-work-page exception-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero exception-hero">
|
||||
<div>
|
||||
<span class="eyebrow">现场提报</span>
|
||||
<h1>异常处理</h1>
|
||||
<p>先校验订单,再选择异常类型与原因,补充现场信息后提交。</p>
|
||||
</div>
|
||||
<div class="hero-user">
|
||||
<strong>{{ currentUser.name || '未登录' }}</strong>
|
||||
<span>{{ currentUser.code || '请先登录' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
<div v-for="item in summaryItems" :key="item.label" class="summary-card">
|
||||
<strong :class="item.type">{{ item.value }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="content-grid">
|
||||
<main class="main-column">
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>1</span><div><h2>扫描或录入单号</h2><p>支持订单号、整机编码或设备条码</p></div></div>
|
||||
<div class="inline-form">
|
||||
<el-input v-model="form.orderNo" size="large" clearable placeholder="请输入单号 / 条码" @keyup.enter="checkOrder" />
|
||||
<el-button type="primary" size="large" :loading="checking" @click="checkOrder">校验单号</el-button>
|
||||
</div>
|
||||
<div v-if="orderMessage" class="validation" :class="orderValid ? 'success' : 'error'">{{ orderMessage }}</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>2</span><div><h2>选择处理类型</h2><p>根据现场异常选择对应的处理分类</p></div><em>必选</em></div>
|
||||
<div class="option-grid type-grid">
|
||||
<button v-for="item in typeOptions" :key="item.value" type="button" :class="['choice-card', { active: form.type === item.value }]" @click="selectType(item.value)">
|
||||
<strong>{{ item.label }}</strong><small>{{ item.desc || '按异常设置执行处理' }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sub-title"><h2>3. 选择异常原因</h2><span>快速点选</span></div>
|
||||
<div class="option-grid reason-grid">
|
||||
<button v-for="item in activeReasons" :key="item.value" type="button" :class="['reason-card', { active: form.reason === item.value }]" @click="selectReason(item.value)">{{ item.label }}</button>
|
||||
</div>
|
||||
<div v-if="activeIssues.length" class="issue-box">
|
||||
<div class="sub-title"><h2>具体问题</h2><span>{{ activeReasons.find(item => item.value === form.reason)?.label }}</span></div>
|
||||
<el-select v-model="form.issue" filterable clearable class="full-width" placeholder="请选择具体问题">
|
||||
<el-option v-for="item in activeIssues" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>4</span><div><h2>现场取证</h2><p>最多上传 4 张现场照片</p></div><em class="optional">选填</em></div>
|
||||
<el-upload v-model:file-list="imageFiles" list-type="picture-card" :auto-upload="false" :limit="4" accept="image/*" :on-exceed="handleImageExceed">
|
||||
<span class="upload-plus">+</span>
|
||||
</el-upload>
|
||||
<el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="补充现场情况、处理建议、责任工位等" />
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button size="large" @click="resetForm">重置</el-button>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="submit">提交异常</el-button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="side-column">
|
||||
<section class="panel operator-panel"><h2>提报信息</h2><div><span>提报人</span><strong>{{ currentUser.name || '-' }}</strong></div><div><span>工号</span><strong>{{ currentUser.code || '-' }}</strong></div><div><span>提交时间</span><strong>{{ nowText }}</strong></div></section>
|
||||
<section class="panel"><div class="side-title"><h2>最近异常</h2><span>现场回看</span></div><el-empty v-if="!recentExceptions.length" description="暂无异常记录" :image-size="70" /><div v-for="item in recentExceptions" :key="item.id" class="recent-item"><div><strong>{{ item.order_no || '-' }}</strong><p>{{ item.type_name || '-' }} · {{ [item.reason_name, item.issue_name].filter(Boolean).join(' · ') }}</p></div><el-tag :type="item.status === 2 ? 'success' : item.status === 1 ? 'warning' : 'danger'">{{ item.status_name || '待处理' }}</el-tag></div></section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { UploadUserFile } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMemberStore } from '~/stores/member'
|
||||
import { checkExceptionOrder, getExceptionSettings, getExceptionStatistics, getRecentExceptions, submitExceptionRecord, uploadProduceImage, type ProduceExceptionRecord, type ProduceExceptionSetting } from '~/apps/produce/api/exception'
|
||||
|
||||
const memberStore = useMemberStore()
|
||||
const currentUser = computed(() => ({ name: String(memberStore.info?.nickname || memberStore.info?.real_name || memberStore.info?.username || ''), code: String(memberStore.info?.username || memberStore.info?.user_code || '') }))
|
||||
const form = ref({ orderNo: '', type: '', reason: '', issue: '', remark: '' })
|
||||
const settings = ref<ProduceExceptionSetting[]>([])
|
||||
const recentExceptions = ref<ProduceExceptionRecord[]>([])
|
||||
const summary = ref({ pending: 0, review: 0, done: 0 })
|
||||
const imageFiles = ref<UploadUserFile[]>([])
|
||||
const checking = ref(false)
|
||||
const submitting = ref(false)
|
||||
const orderValid = ref(false)
|
||||
const orderMessage = ref('')
|
||||
const nowText = ref('')
|
||||
const typeOptions = computed(() => settings.value.filter(item => item.setting_type === 'type').map(toOption))
|
||||
const activeReasons = computed(() => settings.value.filter(item => item.setting_type === 'reason' && String(item.parent_id || '') === String(form.value.type)).map(toOption))
|
||||
const activeIssues = computed(() => settings.value.filter(item => item.setting_type === 'issue' && String(item.parent_id || '') === String(form.value.reason)).map(toOption))
|
||||
const summaryItems = computed(() => [{ label: '待处理', value: summary.value.pending, type: 'danger' }, { label: '待复核', value: summary.value.review, type: 'warning' }, { label: '已处理', value: summary.value.done, type: 'success' }])
|
||||
const toOption = (item: ProduceExceptionSetting) => ({ value: String(item.id), label: item.name, desc: item.remark || '' })
|
||||
|
||||
const updateNow = () => { nowText.value = new Date().toLocaleString('zh-CN', { hour12: false }) }
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [settingData, statisticData, recentData] = await Promise.all([getExceptionSettings(), getExceptionStatistics({ user_code: currentUser.value.code }), getRecentExceptions({ user_code: currentUser.value.code, limit: 5 })])
|
||||
settings.value = Array.isArray(settingData) ? settingData : []
|
||||
summary.value = statisticData || summary.value
|
||||
recentExceptions.value = Array.isArray(recentData) ? recentData : []
|
||||
if (!form.value.type) selectType(typeOptions.value[0]?.value || '')
|
||||
} catch { /* 请求层已提示错误 */ }
|
||||
}
|
||||
const selectType = (value: string) => { form.value.type = value; form.value.reason = activeReasons.value[0]?.value || ''; form.value.issue = activeIssues.value[0]?.value || '' }
|
||||
const selectReason = (value: string) => { form.value.reason = value; form.value.issue = activeIssues.value[0]?.value || '' }
|
||||
const checkOrder = async () => {
|
||||
const orderNo = form.value.orderNo.trim()
|
||||
if (!orderNo) { orderMessage.value = '请输入单号或条码'; orderValid.value = false; return }
|
||||
checking.value = true
|
||||
try { const result = await checkExceptionOrder(orderNo); orderValid.value = true; orderMessage.value = `已匹配${result?.reference_name || '记录'}` } catch { orderValid.value = false; orderMessage.value = '未找到对应订单或整机记录' } finally { checking.value = false }
|
||||
}
|
||||
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
|
||||
const resetForm = () => { form.value = { orderNo: '', type: typeOptions.value[0]?.value || '', reason: '', issue: '', remark: '' }; imageFiles.value = []; orderValid.value = false; orderMessage.value = ''; selectType(form.value.type); updateNow() }
|
||||
const submit = async () => {
|
||||
if (!orderValid.value || !form.value.orderNo.trim()) { await checkOrder(); if (!orderValid.value) return }
|
||||
if (!form.value.type || !form.value.reason || (activeIssues.value.length && !form.value.issue)) { ElMessage.warning('请选择处理类型、异常原因和具体问题'); return }
|
||||
if (!currentUser.value.code) { ElMessage.warning('请先登录后再提报异常'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
const images: string[] = []
|
||||
for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'exception'); if (url) images.push(url) }
|
||||
const response = await submitExceptionRecord({ order_no: form.value.orderNo.trim(), user_code: currentUser.value.code, type_id: form.value.type, reason_id: form.value.reason, issue_id: form.value.issue || '', images, remark: form.value.remark.trim() })
|
||||
if (response?.code !== 0) { ElMessage.error(response?.msg || '异常提交失败'); return }
|
||||
ElMessage.success('异常已提交'); resetForm(); await loadData()
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
onMounted(() => { updateNow(); void loadData() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-work-page { min-height: calc(100vh - 60px); padding: 28px; background: #f5f7fb; color: #17233d; }
|
||||
.page-shell { max-width: 1440px; margin: 0 auto; }
|
||||
.page-hero { display:flex; align-items:center; justify-content:space-between; gap:24px; padding:32px 36px; border-radius:22px; color:#fff; box-shadow:0 14px 32px rgba(37,99,235,.12); }
|
||||
.exception-hero { background:linear-gradient(135deg,#ff9d42,#ef625f); }
|
||||
.eyebrow { font-size:13px; opacity:.85; } h1 { margin:8px 0 0; font-size:30px; } .page-hero p { margin:8px 0 0; opacity:.86; }
|
||||
.hero-user { min-width:150px; padding:16px 20px; border-radius:14px; background:rgba(255,255,255,.16); text-align:right; } .hero-user strong,.hero-user span { display:block; } .hero-user span { margin-top:6px; font-size:13px; opacity:.8; }
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin:20px 0; } .summary-card,.panel { background:#fff; border:1px solid #e7edf5; border-radius:18px; box-shadow:0 8px 24px rgba(36,59,92,.04); } .summary-card { padding:18px 22px; } .summary-card strong,.summary-card span { display:block; } .summary-card strong { font-size:28px; } .summary-card span { margin-top:6px; color:#8a94a6; font-size:13px; } .danger { color:#ef625f; } .warning { color:#e59a2f; } .success { color:#26b36c; }
|
||||
.content-grid { display:grid; grid-template-columns:minmax(0,1fr) 350px; gap:20px; align-items:start; } .main-column,.side-column { display:flex; flex-direction:column; gap:20px; } .panel { padding:26px; } .panel-title,.side-title,.sub-title { display:flex; align-items:flex-start; gap:14px; } .panel-title > span { display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:#fff0e5; color:#e97020; font-weight:700; flex:none; } h2 { margin:0; font-size:17px; } .panel-title p { margin:6px 0 0; color:#9aa4b4; font-size:13px; } .panel-title em { margin-left:auto; padding:5px 10px; border-radius:999px; color:#e97020; background:#fff3e8; font-size:12px; font-style:normal; } .panel-title em.optional { color:#8793a6; background:#f3f5f8; }
|
||||
.inline-form { display:flex; gap:12px; margin-top:20px; } .inline-form .el-input { flex:1; } .validation { margin-top:10px; font-size:13px; } .validation.success { color:#26b36c; } .validation.error { color:#ef625f; } .option-grid { display:grid; gap:12px; margin-top:18px; } .type-grid { grid-template-columns:repeat(2,1fr); } button { font:inherit; cursor:pointer; } .choice-card,.reason-card { border:1px solid #e6edf5; border-radius:14px; background:#f8fafc; text-align:left; transition:.2s; } .choice-card { padding:16px; } .choice-card strong,.choice-card small { display:block; } .choice-card strong { color:#2b3853; } .choice-card small { margin-top:8px; color:#9aa4b4; } .choice-card.active,.reason-card.active { border-color:#ff9a3c; background:#fff5ed; color:#e97020; } .sub-title { align-items:center; justify-content:space-between; margin-top:26px; } .sub-title span,.side-title span { color:#9aa4b4; font-size:12px; } .reason-grid { grid-template-columns:repeat(3,1fr); } .reason-card { min-height:46px; padding:8px; text-align:center; color:#56647a; } .issue-box { margin-top:18px; padding:16px; border-radius:14px; background:#fffaf5; } .issue-box .sub-title { margin:0 0 12px; } .full-width { width:100%; }
|
||||
:deep(.el-upload--picture-card),:deep(.el-upload-list__item) { width:92px; height:92px; } .upload-plus { font-size:30px; color:#ff9a3c; } .remark-input { margin-top:18px; } .form-actions { display:flex; justify-content:flex-end; gap:12px; } .operator-panel h2 { margin-bottom:18px; } .operator-panel div { display:flex; justify-content:space-between; padding:13px 0; border-bottom:1px solid #eef2f7; font-size:13px; } .operator-panel div:last-child { border-bottom:0; } .operator-panel span { color:#9aa4b4; } .side-title { align-items:center; justify-content:space-between; } .recent-item { display:flex; justify-content:space-between; gap:10px; padding:16px 0; border-bottom:1px solid #eef2f7; } .recent-item:last-child { border-bottom:0; } .recent-item strong { font-size:14px; } .recent-item p { margin:6px 0 0; color:#8a94a6; font-size:12px; }
|
||||
@media (max-width: 900px) { .produce-work-page { padding:16px; } .content-grid { grid-template-columns:1fr; } .side-column { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width: 640px) { .page-hero { padding:24px; align-items:flex-start; flex-direction:column; } .hero-user { align-self:stretch; text-align:left; } .summary-grid,.side-column { grid-template-columns:1fr; } .type-grid,.reason-grid { grid-template-columns:1fr; } .inline-form { flex-direction:column; } .form-actions { justify-content:stretch; } .form-actions .el-button { flex:1; } }
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="produce-query-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<span class="eyebrow">生产追溯中心</span>
|
||||
<h1>综合查询工作台</h1>
|
||||
<p>按订单号查看生产、异常和维修全过程;也可以独立查询各类业务记录。</p>
|
||||
</div>
|
||||
<div class="hero-badge"><strong>{{ timeline.total }}</strong><span>订单时间线记录</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="panel-heading"><div><h2>订单全流程时间线</h2><p>订单号查询会汇总扫码、整机、异常和维修记录,并按发生时间排序。</p></div></div>
|
||||
<div class="search-row">
|
||||
<el-input v-model="timelineOrderNo" size="large" clearable placeholder="请输入订单号" @keyup.enter="loadTimeline">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" size="large" :loading="timelineLoading" @click="loadTimeline">查询时间线</el-button>
|
||||
</div>
|
||||
<el-alert v-if="timelineMessage" :title="timelineMessage" :type="timeline.total ? 'success' : 'info'" :closable="false" class="timeline-alert" />
|
||||
<el-empty v-if="timelineLoaded && !timelineLoading && !timeline.items.length" description="该订单暂无相关记录" :image-size="90" />
|
||||
<div v-else-if="timeline.items.length" class="timeline-list">
|
||||
<el-table :data="timeline.items" stripe class="timeline-table" empty-text="暂无时间线记录">
|
||||
<el-table-column prop="event_date" label="发生时间" width="170" />
|
||||
<el-table-column label="记录类型" width="112">
|
||||
<template #default="scope"><el-tag :type="tagType(scope.row.record_type)" effect="light" round>{{ scope.row.record_type_name }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单 / 产品" min-width="220" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="timeline-content-cell">
|
||||
<span class="content-mark" aria-hidden="true" />
|
||||
<div class="timeline-main-text">
|
||||
<strong>{{ scope.row.order_no || timeline.order_no || '-' }}</strong>
|
||||
<small v-if="scope.row.product_code">产品:{{ scope.row.product_code }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工序节点" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="timeline-node-cell">
|
||||
<strong>{{ scope.row.process_name || '未指定工序' }}</strong>
|
||||
<small v-if="scope.row.step_name">工位:{{ scope.row.step_name }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态 / 结果" min-width="155" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="timeline-status">{{ [scope.row.status_name, scope.row.result_name].filter(Boolean).join(' · ') || '已记录' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="78" align="right">
|
||||
<template #default="scope">{{ scope.row.quantity ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务详情" min-width="280" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.record_type === 'exception'" class="timeline-detail-cell">
|
||||
<span><b>异常原因</b>{{ scope.row.exception_reason || '未填写' }}</span>
|
||||
<span><b>具体问题</b>{{ scope.row.exception_content || '未填写' }}</span>
|
||||
</div>
|
||||
<div v-else-if="scope.row.record_type === 'repair'" class="timeline-detail-cell">
|
||||
<span><b>物料分类</b>{{ scope.row.material_category || '未填写' }}</span>
|
||||
<span><b>故障描述</b>{{ scope.row.fault_description || '未填写' }}</span>
|
||||
</div>
|
||||
<span v-else class="muted-placeholder">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="130" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="timeline-operator-cell">
|
||||
<span>{{ scope.row.operator || '未记录' }}</span>
|
||||
<small v-if="scope.row.user_code">工号:{{ scope.row.user_code }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel independent-panel">
|
||||
<div class="panel-heading"><div><h2>独立记录查询</h2><p>切换记录类型,按条件分页查询。</p></div><el-button @click="resetIndependent">重置</el-button></div>
|
||||
<el-tabs v-model="filters.record_type" class="query-tabs" @tab-change="handleRecordTypeChange">
|
||||
<el-tab-pane label="扫码记录" name="scan" />
|
||||
<el-tab-pane label="整机记录" name="product" />
|
||||
<el-tab-pane label="异常记录" name="exception" />
|
||||
<el-tab-pane label="维修记录" name="repair" />
|
||||
</el-tabs>
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent="loadRecords">
|
||||
<el-form-item label="关键词"><el-input v-model="filters.keyword" clearable :placeholder="keywordPlaceholder" /></el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="filters.order_no" clearable placeholder="精确订单号" /></el-form-item>
|
||||
<el-form-item v-if="filters.record_type !== 'repair'" label="用户工号"><el-input v-model="filters.user_code" clearable placeholder="用户工号" /></el-form-item>
|
||||
<el-form-item label="日期范围"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="recordLoading" @click="loadRecords">查询</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table v-loading="recordLoading" :data="recordPage.items" stripe class="record-table" empty-text="暂无查询记录" @row-dblclick="showRecordDetail">
|
||||
<el-table-column prop="event_date" label="发生时间" width="170" />
|
||||
<el-table-column prop="record_type_name" label="记录类型" width="112"><template #default="scope"><el-tag :type="tagType(scope.row.record_type)" effect="light" round>{{ scope.row.record_type_name }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="product_code" label="产品编码" min-width="145" show-overflow-tooltip />
|
||||
<el-table-column label="工序节点" min-width="160" show-overflow-tooltip><template #default="scope">{{ [scope.row.process_name, scope.row.step_name].filter(Boolean).join(' / ') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="状态 / 结果" min-width="145" show-overflow-tooltip><template #default="scope">{{ [scope.row.status_name, scope.row.result_name].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="操作人" min-width="125" show-overflow-tooltip><template #default="scope">{{ scope.row.operator || scope.row.user_code || '未记录' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="190" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div class="pagination-row"><span>共 {{ recordPage.total }} 条</span><el-pagination v-model:current-page="recordPage.page" v-model:page-size="recordPage.limit" layout="prev, pager, next, sizes" :page-sizes="[20, 50, 100]" :total="recordPage.total" @current-change="loadRecords" @size-change="loadRecords" /></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { queryProduceRecords, queryProduceTimeline, type ProduceQueryRecord, type ProduceQueryRecordType } from '~/apps/produce/api/query'
|
||||
|
||||
const timelineOrderNo = ref('')
|
||||
const timelineLoading = ref(false)
|
||||
const timelineLoaded = ref(false)
|
||||
const timelineMessage = ref('')
|
||||
const timeline = reactive<{ order_no: string; total: number; items: ProduceQueryRecord[] }>({ order_no: '', total: 0, items: [] })
|
||||
const filters = reactive<{ record_type: ProduceQueryRecordType; keyword: string; order_no: string; user_code: string; start_date?: string; end_date?: string }>({ record_type: 'scan', keyword: '', order_no: '', user_code: '' })
|
||||
const dateRange = ref<string[]>([])
|
||||
const recordLoading = ref(false)
|
||||
const recordPage = reactive({ items: [] as ProduceQueryRecord[], total: 0, page: 1, limit: 20 })
|
||||
|
||||
const tagTypes: Record<ProduceQueryRecordType, 'primary' | 'success' | 'danger' | 'warning'> = { scan: 'primary', product: 'success', exception: 'danger', repair: 'warning' }
|
||||
const tagType = (type: ProduceQueryRecordType) => tagTypes[type]
|
||||
const keywordPlaceholder = computed(() => filters.record_type === 'repair' ? '维修单、产品、人员或备注' : '订单、产品、人员或备注')
|
||||
const handleRecordTypeChange = () => { recordPage.page = 1; void loadRecords() }
|
||||
const showRecordDetail = (row: ProduceQueryRecord) => { ElMessage.info(`${row.record_type_name}详情功能待接入`) }
|
||||
const loadTimeline = async () => { const orderNo = timelineOrderNo.value.trim(); if (!orderNo) return ElMessage.warning('请输入订单号'); timelineLoading.value = true; timelineLoaded.value = false; timelineMessage.value = ''; try { const data = await queryProduceTimeline(orderNo); timeline.order_no = data?.order_no || orderNo; timeline.total = Number(data?.total || 0); timeline.items = Array.isArray(data?.items) ? data.items : []; timelineMessage.value = timeline.total ? `已找到 ${timeline.total} 条全流程记录` : '该订单暂无相关记录'; timelineLoaded.value = true } catch { timeline.order_no = orderNo; timeline.total = 0; timeline.items = []; timelineMessage.value = '订单查询失败,请检查订单号后重试'; timelineLoaded.value = true } finally { timelineLoading.value = false } }
|
||||
const loadRecords = async () => { recordLoading.value = true; try { const data = await queryProduceRecords({ ...filters, ...(dateRange.value.length ? { start_date: dateRange.value[0], end_date: dateRange.value[1] } : {}), page: recordPage.page, limit: recordPage.limit }); recordPage.items = Array.isArray(data?.items) ? data.items : []; recordPage.total = Number(data?.total || 0) } catch { recordPage.items = []; recordPage.total = 0; ElMessage.error('记录查询失败,请稍后重试') } finally { recordLoading.value = false } }
|
||||
const resetIndependent = () => { filters.record_type = 'scan'; filters.keyword = ''; filters.order_no = ''; filters.user_code = ''; dateRange.value = []; recordPage.page = 1; void loadRecords() }
|
||||
onMounted(() => { void loadRecords() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-query-page { min-height: calc(100vh - 60px); padding: 28px; color: #17233d; background: #f5f7fb; }
|
||||
.page-shell { max-width: 1440px; margin: 0 auto; }
|
||||
.page-hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 32px 36px; color: #fff; background: linear-gradient(135deg, #315efb, #6a67df); border-radius: 22px; box-shadow: 0 14px 32px rgba(49, 94, 251, .16); }
|
||||
.eyebrow { font-size: 13px; opacity: .85; }
|
||||
.page-hero h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.page-hero p { margin: 8px 0 0; opacity: .86; }
|
||||
.hero-badge { min-width: 150px; padding: 16px 20px; text-align: right; background: rgba(255, 255, 255, .16); border-radius: 14px; }
|
||||
.hero-badge strong, .hero-badge span { display: block; }
|
||||
.hero-badge strong { font-size: 28px; }
|
||||
.hero-badge span { margin-top: 5px; font-size: 13px; opacity: .82; }
|
||||
.panel { margin-top: 20px; padding: 26px; background: #fff; border: 1px solid #e7edf5; border-radius: 18px; box-shadow: 0 8px 24px rgba(36, 59, 92, .04); }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.panel-heading h2 { margin: 0; font-size: 18px; }
|
||||
.panel-heading p { margin: 6px 0 0; color: #8a94a6; font-size: 13px; }
|
||||
.search-row { display: flex; gap: 12px; }
|
||||
.search-row .el-input { flex: 1; }
|
||||
.timeline-alert { margin-top: 16px; }
|
||||
.timeline-list { margin: 24px 0 0; overflow-x: auto; }
|
||||
.timeline-table, .record-table { min-width: 1100px; overflow: hidden; border: 1px solid #e7edf5; border-radius: 12px; }
|
||||
.timeline-table :deep(.el-table__header th), .record-table :deep(.el-table__header th) { height: 48px; color: #5d6b82; font-size: 13px; font-weight: 600; background: #f7f9fc; }
|
||||
.timeline-table :deep(.el-table__cell), .record-table :deep(.el-table__cell) { padding: 12px 0; color: #34425b; font-size: 13px; }
|
||||
.timeline-table :deep(.el-table__row:hover > td), .record-table :deep(.el-table__row:hover > td) { background: #f4f7ff !important; }
|
||||
.timeline-content-cell, .timeline-node-cell { display: flex; align-items: center; min-width: 0; }
|
||||
.timeline-content-cell { gap: 10px; }
|
||||
.timeline-main-text, .timeline-node-cell, .timeline-operator-cell { min-width: 0; }
|
||||
.timeline-content-cell strong, .timeline-node-cell strong { display: block; overflow: hidden; color: #24324b; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.timeline-content-cell small, .timeline-node-cell small, .timeline-operator-cell small { display: block; margin-top: 4px; overflow: hidden; color: #8a94a6; font-size: 12px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.content-mark { width: 8px; height: 8px; flex: none; background: #315efb; border-radius: 50%; box-shadow: 0 0 0 4px #e5ebff; }
|
||||
.timeline-status { color: #3157c8; font-weight: 500; }
|
||||
.timeline-detail-cell { display: grid; gap: 5px; min-width: 0; line-height: 1.35; }
|
||||
.timeline-detail-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.timeline-detail-cell b { display: inline-block; min-width: 64px; margin-right: 6px; color: #8a94a6; font-size: 12px; font-weight: 500; }
|
||||
.muted-placeholder { color: #b5bdca; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.record-table { margin-top: 14px; }
|
||||
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; color: #8a94a6; font-size: 13px; }
|
||||
.pagination-row .el-pagination { margin-left: auto; }
|
||||
@media (max-width: 640px) { .produce-query-page { padding: 16px; } .page-hero { align-items: flex-start; flex-direction: column; padding: 24px; } .hero-badge { align-self: stretch; text-align: left; } .search-row { flex-direction: column; } .panel { padding: 18px; } .pagination-row { align-items: flex-start; flex-direction: column; } .pagination-row .el-pagination { margin-left: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="produce-work-page repair-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero repair-hero">
|
||||
<div><span class="eyebrow">维修工作台</span><h1>维修处理</h1><p>扫描设备,快速完成故障判断、备件登记与维修记录。</p></div>
|
||||
<el-icon :size="44"><Tools /></el-icon>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
<div v-for="item in summaries" :key="item.label" class="summary-card"><strong :class="item.type">{{ item.value }}</strong><span>{{ item.label }}</span><el-button v-if="item.action" link type="primary" @click="goTasks">查看任务</el-button></div>
|
||||
</section>
|
||||
|
||||
<div class="content-grid">
|
||||
<main class="main-column">
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>1</span><div><h2>扫描或录入设备</h2><p>支持订单号、整机编码或设备条码</p></div><em>必填</em></div>
|
||||
<div class="inline-form"><el-input v-model="deviceNo" size="large" clearable placeholder="请输入订单号 / 整机编码" @keyup.enter="loadTask" /><el-button type="primary" size="large" :loading="loadingTask" @click="loadTask">查询任务</el-button></div>
|
||||
<div v-if="task" class="task-found"><el-icon><CircleCheckFilled /></el-icon><div><strong>已识别维修对象</strong><span>{{ task.device_no }}</span></div><el-tag type="warning">{{ task.status_name || '待维修' }}</el-tag></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>2</span><div><h2>异常信息</h2><p>来自异常提报的维修任务</p></div><small>{{ task?.reported_date || '待查询设备' }}</small></div>
|
||||
<el-empty v-if="!task" description="查询设备后加载异常任务" :image-size="72" />
|
||||
<div v-else class="info-grid"><div><span>异常类型</span><strong>{{ task.type_name || '-' }}</strong></div><div><span>异常原因</span><strong>{{ reasonText }}</strong></div><div><span>提报人员</span><strong>{{ task.reporter || task.user_code || '-' }}</strong></div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>3</span><div><h2>维修判定</h2><p>选择本次维修的不良类型</p></div><em>必选</em></div>
|
||||
<div class="result-grid"><button v-for="item in resultOptions" :key="item.value" type="button" :class="['result-card', { active: form.repair_result === item.value }]" @click="selectResult(item.value)"><el-icon :size="26"><component :is="item.icon" /></el-icon><strong>{{ item.label }}</strong><small>{{ item.desc }}</small></button></div>
|
||||
</section>
|
||||
|
||||
<section v-if="form.repair_result === 'assembly_mismatch'" class="panel"><div class="panel-title"><span>4</span><div><h2>搭配错误分类</h2><p>选择与本次异常对应的物料分类</p></div><em>必选</em></div><el-checkbox-group v-model="form.category_ids" class="category-grid"><el-checkbox v-for="item in categories" :key="item.id" :label="String(item.id)">{{ item.category_name }}</el-checkbox></el-checkbox-group><el-empty v-if="!categories.length" description="暂无可用物料分类" :image-size="60" /></section>
|
||||
|
||||
<section v-if="form.repair_result === 'operation_defect'" class="panel"><div class="panel-title"><span>4</span><div><h2>作业不良责任</h2><p>选择责任人员,工序步骤随人员记录保存</p></div><em>必选</em></div><el-checkbox-group v-model="form.responsible_ids" class="responsibility-grid"><el-checkbox v-for="item in scanRecords" :key="item.id" :label="String(item.id)"><span>{{ item.operator || item.user_code || '未记录人员' }}</span><small>{{ item.process_name || '-' }} · {{ item.step_name || '-' }}</small></el-checkbox></el-checkbox-group><el-empty v-if="!scanRecords.length" description="当前订单暂无可定责的历史扫码记录" :image-size="60" /></section>
|
||||
|
||||
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '4' : '5' }}</span><div><h2>维修记录</h2><p>登记更换备件和处理说明,便于后续追溯</p></div></div><div class="part-toolbar"><strong>更换备件</strong><div><el-button size="small" @click="openPartDialog">输入商品编码</el-button><el-button size="small" type="primary" @click="openPartDialog">添加备件</el-button></div></div><el-empty v-if="!form.parts.length" description="暂未添加备件" :image-size="60" /><div v-for="(part, index) in form.parts" :key="part.id" class="part-row"><div class="part-index">{{ index + 1 }}</div><div class="part-info"><strong>{{ part.product_name || part.product_code }}</strong><span>{{ part.product_code }}{{ part.category_name ? ` · ${part.category_name}` : '' }}</span><el-radio-group v-model="part.source" size="small"><el-radio-button label="new">新料</el-radio-button><el-radio-button label="after_sales">售后</el-radio-button><el-radio-button label="refurbished">返修</el-radio-button></el-radio-group><el-select v-model="part.fault_description_ids" multiple collapse-tags filterable clearable placeholder="选择故障点" class="fault-select"><el-option v-for="fault in faultsForPart(part)" :key="fault.id" :label="fault.description" :value="String(fault.id)" /></el-select></div><el-input-number v-model="part.quantity" :min="1" size="small" /><el-button link type="danger" @click="removePart(index)">删除</el-button></div><el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="记录维修过程、处理建议或补充说明" /></section>
|
||||
|
||||
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '5' : '6' }}</span><div><h2>维修凭证</h2><p>可上传维修完成后的现场照片</p></div><em class="optional">选填</em></div><el-upload v-model:file-list="imageFiles" list-type="picture-card" :auto-upload="false" :limit="4" accept="image/*" :on-exceed="handleImageExceed"><span class="upload-plus">+</span></el-upload></section>
|
||||
|
||||
<div class="form-actions"><el-button size="large" @click="resetForm">重置</el-button><el-button type="primary" size="large" :loading="submitting" @click="completeRepair">完成维修</el-button></div>
|
||||
</main>
|
||||
<aside class="side-column"><section class="panel operator-panel"><h2>当前操作人</h2><div><span>姓名</span><strong>{{ currentUser.name || '-' }}</strong></div><div><span>工号</span><strong>{{ currentUser.code || '-' }}</strong></div><div><span>当前任务</span><strong>{{ task?.device_no || '未选择' }}</strong></div></section><section class="panel tip-panel"><h2>处理提示</h2><p>物料不良请登记替换备件;作业不良需选择责任人员;搭配错误需选择对应物料分类。</p><el-button link type="primary" @click="goTasks">查看待维修订单</el-button></section></aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="partDialogVisible" title="添加维修备件" width="460px"><el-input v-model="partCode" autofocus placeholder="请输入商品编码" @keyup.enter="addPart" /><template #footer><el-button @click="partDialogVisible = false">取消</el-button><el-button type="primary" :loading="loadingPart" @click="addPart">确定</el-button></template></el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CircleCheckFilled, Grid, EditPen, Tools, Box } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { UploadUserFile } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { navigateTo, useRoute } from 'nuxt/app'
|
||||
import { useMemberStore } from '~/stores/member'
|
||||
import { getFaultDescriptionOptions, getMaterialCategories, getRepairStatistics, getRepairTask, getScanRecords, lookupMaterial, submitRepairRecord, uploadProduceImage, type ProduceRepairTask } from '~/apps/produce/api/exception'
|
||||
|
||||
interface Part { id: string; product_code: string; product_name?: string; category_id?: string | number; category_name?: string; source: string; quantity: number; fault_description_ids: string[] }
|
||||
interface ScanRecord { id: string | number; process_name?: string; step_name?: string; operator?: string; user_code?: string }
|
||||
interface FaultDescription { id: string | number; category_id: string | number; description: string }
|
||||
const memberStore = useMemberStore()
|
||||
const currentUser = computed(() => ({ name: String(memberStore.info?.nickname || memberStore.info?.real_name || memberStore.info?.username || ''), code: String(memberStore.info?.username || memberStore.info?.user_code || '') }))
|
||||
const summaries = ref([{ label: '待维修', value: 0, type: 'warning', action: true }, { label: '处理中', value: 0, type: 'primary' }, { label: '今日完成', value: 0, type: 'success' }])
|
||||
const resultOptions = [{ label: '物料不良', value: 'material_defect', desc: '物料本身存在异常', icon: Box }, { label: '作业不良', value: 'operation_defect', desc: '作业过程导致异常', icon: EditPen }, { label: '搭配错误', value: 'assembly_mismatch', desc: '物料搭配或装配错误', icon: Grid }]
|
||||
const deviceNo = ref('')
|
||||
const task = ref<ProduceRepairTask | null>(null)
|
||||
const categories = ref<Array<{ id: string | number; category_name: string }>>([])
|
||||
const scanRecords = ref<ScanRecord[]>([])
|
||||
const faults = ref<FaultDescription[]>([])
|
||||
const imageFiles = ref<UploadUserFile[]>([])
|
||||
const form = ref({ repair_result: 'material_defect', category_ids: [] as string[], responsible_ids: [] as string[], parts: [] as Part[], remark: '' })
|
||||
const loadingTask = ref(false)
|
||||
const loadingPart = ref(false)
|
||||
const submitting = ref(false)
|
||||
const partDialogVisible = ref(false)
|
||||
const partCode = ref('')
|
||||
const reasonText = computed(() => [task.value?.reason_name, task.value?.issue_name].filter(Boolean).join(' · ') || '-')
|
||||
|
||||
const loadStatistics = async () => {
|
||||
const data = await getRepairStatistics({ user_code: currentUser.value.code }).catch(() => null)
|
||||
if (!data) return
|
||||
summaries.value = [
|
||||
{ label: '待维修', value: Number(data.pending || 0), type: 'warning', action: true },
|
||||
{ label: '处理中', value: Number(data.processing || 0), type: 'primary' },
|
||||
{ label: '今日完成', value: Number(data.completed_today || 0), type: 'success' },
|
||||
]
|
||||
}
|
||||
const loadTask = async () => { const value = deviceNo.value.trim(); if (!value || loadingTask.value) return ElMessage.warning('请输入设备编码或订单号'); loadingTask.value = true; try { task.value = await getRepairTask(value); form.value.responsible_ids = []; scanRecords.value = []; if (task.value?.device_no) { const result = await getScanRecords({ order_no: task.value.device_no, limit: 50 }); scanRecords.value = Array.isArray(result?.items) ? result.items : []; } } finally { loadingTask.value = false } }
|
||||
const selectResult = (value: string) => { form.value.repair_result = value; if (value !== 'assembly_mismatch') form.value.category_ids = []; if (value !== 'operation_defect') form.value.responsible_ids = [] }
|
||||
const openPartDialog = () => { partCode.value = ''; partDialogVisible.value = true }
|
||||
const addPart = async () => { const code = partCode.value.trim(); if (!code) return ElMessage.warning('请输入商品编码'); if (form.value.parts.some((item: Part) => item.product_code === code)) return ElMessage.warning('该物料已添加'); loadingPart.value = true; try { const material = await lookupMaterial(code); const part: Part = { id: `${Date.now()}-${code}`, product_code: material.product_code || code, product_name: material.product_name, category_id: material.category_id, category_name: material.category_name, source: 'new', quantity: 1, fault_description_ids: [] }; form.value.parts.push(part); await loadFaults(); partDialogVisible.value = false } finally { loadingPart.value = false } }
|
||||
const loadFaults = async () => { const ids = [...new Set(form.value.parts.map((item: Part) => String(item.category_id || '')).filter(Boolean))]; faults.value = ids.length ? await getFaultDescriptionOptions(ids) : [] }
|
||||
const faultsForPart = (part: Part) => faults.value.filter((item: FaultDescription) => String(item.category_id) === String(part.category_id))
|
||||
const removePart = (index: number) => { form.value.parts.splice(index, 1); void loadFaults() }
|
||||
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
|
||||
const resetForm = () => { deviceNo.value = ''; task.value = null; scanRecords.value = []; form.value = { repair_result: 'material_defect', category_ids: [], responsible_ids: [], parts: [], remark: '' }; imageFiles.value = [] }
|
||||
const completeRepair = async () => { if (!task.value?.id) return ElMessage.warning('请先查询有效的维修任务'); if (!currentUser.value.code) return ElMessage.warning('请先登录'); if (form.value.repair_result === 'assembly_mismatch' && !form.value.category_ids.length) return ElMessage.warning('请选择搭配错误分类'); if (form.value.repair_result === 'operation_defect' && !form.value.responsible_ids.length) return ElMessage.warning('请选择责任人员'); submitting.value = true; try { const images: string[] = []; for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'repair'); if (url) images.push(url) } const response = await submitRepairRecord({ exception_id: task.value.id, user_code: currentUser.value.code, repair_result: form.value.repair_result, images, category_ids: form.value.category_ids, parts: form.value.parts.map((part: Part) => ({ product_code: part.product_code, source: part.source, quantity: part.quantity, fault_description_ids: part.fault_description_ids })), operation_fault_steps: form.value.responsible_ids, responsible_operators: form.value.responsible_ids, remark: form.value.remark.trim() }); if (response?.code !== 0) { ElMessage.error(response?.msg || '维修提交失败'); return } ElMessage.success('维修记录已完成'); resetForm(); void loadStatistics() } finally { submitting.value = false } }
|
||||
const goTasks = () => navigateTo('/produce/repair/tasks')
|
||||
onMounted(() => { void loadStatistics(); void getMaterialCategories().then((data: Array<{ id: string | number; category_name: string }>) => { categories.value = Array.isArray(data) ? data : [] }); const query = useRoute().query.device_no; if (query) { deviceNo.value = String(query); void loadTask() } })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-work-page{min-height:calc(100vh - 60px);padding:28px;background:#f5f7fb;color:#17233d}.page-shell{max-width:1440px;margin:0 auto}.page-hero{display:flex;align-items:center;justify-content:space-between;padding:32px 36px;border-radius:22px;color:#fff;box-shadow:0 14px 32px rgba(37,99,235,.12)}.repair-hero{background:linear-gradient(135deg,#2f6cf6,#35bfa3)}.eyebrow{font-size:13px;opacity:.85}h1{margin:8px 0 0;font-size:30px}.page-hero p{margin:8px 0 0;opacity:.86}.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:20px 0}.summary-card,.panel{background:#fff;border:1px solid #e7edf5;border-radius:18px;box-shadow:0 8px 24px rgba(36,59,92,.04)}.summary-card{display:flex;align-items:center;gap:12px;padding:18px 22px}.summary-card strong{font-size:28px}.summary-card span{color:#8a94a6;font-size:13px}.summary-card .el-button{margin-left:auto}.warning{color:#e59a2f}.primary{color:#2563eb}.success{color:#26b36c}.content-grid{display:grid;grid-template-columns:minmax(0,1fr) 350px;gap:20px;align-items:start}.main-column,.side-column{display:flex;flex-direction:column;gap:20px}.panel{padding:26px}.panel-title{display:flex;align-items:flex-start;gap:14px}.panel-title>span{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#eaf2ff;color:#2563eb;font-weight:700;flex:none}.panel-title h2{margin:0;font-size:17px}.panel-title p{margin:6px 0 0;color:#9aa4b4;font-size:13px}.panel-title em{margin-left:auto;padding:5px 10px;border-radius:999px;color:#2563eb;background:#edf4ff;font-size:12px;font-style:normal}.panel-title em.optional{color:#8793a6;background:#f3f5f8}.panel-title small{margin-left:auto;color:#9aa4b4}.inline-form{display:flex;gap:12px;margin-top:20px}.inline-form .el-input{flex:1}.task-found{display:flex;align-items:center;gap:12px;margin-top:16px;padding:14px;border-radius:12px;background:#f0fbf6;color:#26b36c}.task-found>div{display:flex;flex-direction:column;gap:4px;flex:1}.task-found span{color:#2b3853;font-weight:600}.task-found .el-tag{margin-left:auto}.info-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:22px}.info-grid div{padding:14px;border-radius:12px;background:#f8fafc}.info-grid span,.info-grid strong{display:block}.info-grid span{color:#9aa4b4;font-size:12px}.info-grid strong{margin-top:8px;font-size:14px;word-break:break-all}.result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:20px}.result-card{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:18px;border:1px solid #e6edf5;border-radius:14px;background:#f8fafc;color:#56647a;text-align:left;cursor:pointer}.result-card.active{border-color:#409eff;background:#edf4ff;color:#2563eb}.result-card small{color:#9aa4b4}.category-grid,.responsibility-grid{display:flex;flex-wrap:wrap;gap:14px;margin-top:22px}.responsibility-grid .el-checkbox{height:auto;margin:0;padding:12px 16px;border:1px solid #e6edf5;border-radius:12px;background:#f8fafc}.responsibility-grid .el-checkbox.is-checked{border-color:#409eff;background:#edf4ff}.responsibility-grid small{display:block;color:#9aa4b4}.part-toolbar{display:flex;justify-content:space-between;align-items:center;margin:22px 0 12px}.part-row{display:flex;align-items:flex-start;gap:12px;padding:16px 0;border-bottom:1px solid #eef2f7}.part-index{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:#edf4ff;color:#2563eb}.part-info{display:flex;flex-direction:column;gap:8px;flex:1}.part-info>span{color:#8a94a6;font-size:12px}.fault-select{width:100%}.remark-input{margin-top:18px}.form-actions{display:flex;justify-content:flex-end;gap:12px}.operator-panel h2,.tip-panel h2{margin:0 0 18px}.operator-panel div{display:flex;justify-content:space-between;padding:13px 0;border-bottom:1px solid #eef2f7;font-size:13px}.operator-panel div:last-child{border:0}.operator-panel span{color:#9aa4b4}.tip-panel p{color:#718096;font-size:13px;line-height:1.8}.upload-plus{font-size:30px;color:#409eff}:deep(.el-upload--picture-card),:deep(.el-upload-list__item){width:92px;height:92px}@media(max-width:900px){.produce-work-page{padding:16px}.content-grid{grid-template-columns:1fr}.side-column{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.page-hero{padding:24px;align-items:flex-start;flex-direction:column;gap:18px}.summary-grid,.side-column,.info-grid,.result-grid{grid-template-columns:1fr}.inline-form,.form-actions{flex-direction:column}.form-actions .el-button{width:100%}.part-row{flex-wrap:wrap}}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="produce-work-page tasks-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero">
|
||||
<div><span class="eyebrow">维修工作台</span><h1>待维修订单</h1><p>选择订单查看异常详情,并进入维修处理。</p></div>
|
||||
<div class="task-count">{{ tasks.length }} 条</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading"><div><h2>待维修任务</h2><p>按异常提报时间展示尚未处理的维修任务</p></div><el-button :loading="loading" @click="loadTasks">刷新</el-button></div>
|
||||
<el-skeleton v-if="loading" :rows="5" animated />
|
||||
<el-empty v-else-if="!tasks.length" description="暂无待维修订单" :image-size="90" />
|
||||
<div v-else class="task-grid">
|
||||
<article v-for="task in tasks" :key="task.id" class="task-card">
|
||||
<div class="task-card-header"><div><span class="task-label">设备 / 订单号</span><strong>{{ task.device_no }}</strong></div><el-tag type="warning">{{ task.status_name || '待维修' }}</el-tag></div>
|
||||
<div class="task-detail"><span>异常类型</span><strong>{{ task.type_name || '-' }}</strong></div>
|
||||
<div class="task-detail"><span>异常原因</span><strong>{{ reasonText(task) }}</strong></div>
|
||||
<div class="task-footer"><span>{{ task.reported_date || '-' }}</span><span>{{ task.reporter || task.user_code || '-' }}</span></div>
|
||||
<el-button type="primary" class="task-action" @click="startRepair(task.device_no)">开始维修</el-button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getRepairTasks, type ProduceRepairTask } from '~/apps/produce/api/exception'
|
||||
|
||||
const loading = ref(false)
|
||||
const tasks = ref<ProduceRepairTask[]>([])
|
||||
const reasonText = (task: ProduceRepairTask) => [task.reason_name, task.issue_name].filter(Boolean).join(' · ') || '-'
|
||||
const loadTasks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getRepairTasks({ limit: 100 })
|
||||
tasks.value = Array.isArray(result) ? result : []
|
||||
} catch {
|
||||
tasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const startRepair = (deviceNo: string) => navigateTo({ path: '/produce/repair', query: { device_no: deviceNo } })
|
||||
onMounted(() => { void loadTasks() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-work-page{min-height:calc(100vh - 60px);padding:28px;background:#f5f7fb;color:#17233d}.page-shell{max-width:1440px;margin:0 auto}.page-hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:32px 36px;border-radius:22px;color:#fff;background:linear-gradient(135deg,#2f6cf6,#35bfa3);box-shadow:0 14px 32px rgba(37,99,235,.12)}.eyebrow{font-size:13px;opacity:.85}.page-hero h1{margin:8px 0 0;font-size:30px}.page-hero p{margin:8px 0 0;opacity:.86}.task-count{padding:10px 16px;border-radius:999px;background:rgba(255,255,255,.16);font-size:14px}.panel{margin-top:20px;padding:26px;border:1px solid #e7edf5;border-radius:18px;background:#fff;box-shadow:0 8px 24px rgba(36,59,92,.04)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}.panel-heading h2{margin:0;font-size:18px}.panel-heading p{margin:6px 0 0;color:#9aa4b4;font-size:13px}.task-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.task-card{padding:20px;border:1px solid #e7edf5;border-radius:16px;background:#fbfcfe}.task-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.task-label,.task-detail span{display:block;color:#9aa4b4;font-size:12px}.task-card-header strong{display:block;margin-top:7px;color:#2563eb;font-size:16px;word-break:break-all}.task-detail{display:flex;justify-content:space-between;gap:16px;padding:14px 0;border-bottom:1px solid #eef2f7}.task-detail strong{max-width:68%;text-align:right;font-size:13px;word-break:break-all}.task-footer{display:flex;justify-content:space-between;gap:12px;padding:14px 0;color:#8a94a6;font-size:12px}.task-action{width:100%;margin-top:4px}@media(max-width:1000px){.task-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.produce-work-page{padding:16px}.page-hero{padding:24px;align-items:flex-start;flex-direction:column}.task-grid{grid-template-columns:1fr}.panel{padding:18px}}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
export default [
|
||||
{
|
||||
path: '/produce/data-entry',
|
||||
component: () => import('~/apps/produce/pages/data-entry/index.vue'),
|
||||
meta: {
|
||||
title: '数据录入',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/exception',
|
||||
component: () => import('~/apps/produce/pages/exception/index.vue'),
|
||||
meta: {
|
||||
title: '异常处理',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/repair',
|
||||
component: () => import('~/apps/produce/pages/repair/index.vue'),
|
||||
meta: {
|
||||
title: '维修处理',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/repair/tasks',
|
||||
component: () => import('~/apps/produce/pages/repair/tasks.vue'),
|
||||
meta: {
|
||||
title: '待维修订单',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/query',
|
||||
component: () => import('~/apps/produce/pages/query/index.vue'),
|
||||
meta: {
|
||||
title: '综合查询',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user