Files
MrleeAdmin/frontend/scanapp/pages/entry/machine.vue
T
2026-08-31 11:33:59 +08:00

857 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="entry-page machine-page" :class="`theme-${themeMode}`">
<view class="hero-card">
<view>
<text class="hero-title">整机模式</text>
<text class="hero-subtitle">扫描加工单和商品编码,自动使用当前登录用户编码完成录入</text>
</view>
<view class="hero-icon">
<u-icon name="scan" color="#ffffff" size="46" />
</view>
</view>
<view class="config-card">
<view class="card-title">当前配置</view>
<view class="chip-row">
<view class="select-chip" @tap="showPicker('process')">
<text class="chip-label">工序</text>
<text class="chip-value">{{ processLabel }}</text>
</view>
<view class="select-chip" @tap="showPicker('step')">
<text class="chip-label">步骤</text>
<text class="chip-value">{{ stepLabel }}</text>
</view>
</view>
</view>
<view class="form-card">
<view class="card-title">整机录入</view>
<u-form :model="form" :border-bottom="false" label-position="top">
<u-form-item :border-bottom="false" prop="processOrderNo">
<view class="field-card primary-field">
<view class="field-main">
<text class="field-label">加工单号</text>
<u-input
v-model="form.processOrderNo"
:input-mode="pdaMode ? 'none' : 'text'"
clearable
placeholder="请扫描或输入加工单号"
placeholder-style="color: #a0a7b5;"
@focus="handleMachineInputFocus"
/>
</view>
<view class="scan-button" @tap="scanToField('processOrderNo')">
<u-icon name="scan" color="#ffffff" size="36" />
</view>
</view>
</u-form-item>
<u-form-item :border-bottom="false" prop="productCode">
<view class="field-card">
<view class="field-main">
<text class="field-label">商品编码</text>
<u-input
v-model="form.productCode"
:input-mode="pdaMode ? 'none' : 'text'"
clearable
placeholder="请扫描或输入商品编码"
placeholder-style="color: #a0a7b5;"
@focus="handleMachineInputFocus"
/>
</view>
<view class="scan-button" @tap="scanToField('productCode')">
<u-icon name="scan" color="#ffffff" size="36" />
</view>
</view>
</u-form-item>
<u-form-item :border-bottom="false" prop="quantity">
<view class="quantity-card">
<view>
<text class="field-label">数量</text>
<text class="quantity-tip">默认 1,可手动调整</text>
</view>
<view class="quantity-control">
<view class="qty-button" @tap="decreaseQuantity">-</view>
<input v-model="form.quantity" class="qty-input" type="number" />
<view class="qty-button" @tap="increaseQuantity">+</view>
</view>
</view>
</u-form-item>
</u-form>
<view class="action-row">
<view class="submit-secondary" @tap="resetForm">重置</view>
<view class="submit-primary" :class="{ 'is-loading': submitting }" @tap="submitEntry">{{ submitting ? '提交中...' : '提交录入' }}</view>
</view>
</view>
<view class="stats-card">
<view class="stats-header">
<view>
<text class="card-title">今日统计</text>
<text class="card-subtitle">当前工序/步骤整机扫码量</text>
</view>
<view class="total-badge">{{ hideTotal ? '***' : statistics.todayTotal }}</view>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value success">{{ statistics.successTotal }}</text>
<text class="stat-label">成功</text>
</view>
<view class="stat-item">
<text class="stat-value danger">{{ statistics.failedTotal }}</text>
<text class="stat-label">失败</text>
</view>
<view class="stat-item" @tap="hideTotal = !hideTotal">
<text class="stat-value">{{ hideTotal ? '***' : statistics.todayTotal }}</text>
<text class="stat-label">总数</text>
</view>
</view>
</view>
<view class="record-card">
<view class="card-title">最近记录</view>
<view v-for="item in recentRecords" :key="item.id" class="record-item">
<view>
<text class="record-main">{{ item.processOrderNo }}</text>
<text class="record-sub">商品:{{ item.productCode }} / 数量:{{ item.quantity }}</text>
<text class="record-sub">用户编码:{{ item.userCode }}</text>
</view>
<view class="record-status">成功</view>
</view>
</view>
</view>
</template>
<script>
// UTS 原生扫码插件仅在 App-Plus 真机/自定义基座中解析,Web 环境不加载。
// #ifdef APP-PLUS
import { openCodeScan } from '@/uni_modules/mas-code-scan'
// #endif
import { checkMemberUser, getDataEntryConfig, getProcessOptions, getStepOptions, getProductRecentRecords, getProductStatistics, submitProductRecord } from '@/api/produce/data-entry.js'
import { getScanRegionSetting } from '@/utils/scan-settings.js'
export default {
data() {
return {
submitting: false,
scanning: false,
pdaMode: false,
hideTotal: false,
currentUser: {
userCode: 'U001'
},
basicSettings: {},
specificRules: [],
processOptions: [],
stepOptions: [],
selectedProcessIndex: 0,
selectedStepIndex: 0,
form: {
processOrderNo: '',
productCode: '',
quantity: 1
},
statistics: {
todayTotal: 0,
successTotal: 0,
failedTotal: 0
},
recentRecords: []
}
},
onLoad() {
this.loadPdaMode()
this.loadPageData()
},
onShow() {
this.loadPdaMode()
},
computed: {
processLabel() {
const item = this.processOptions[this.selectedProcessIndex]
return item ? item.label : '请选择'
},
stepLabel() {
const item = this.stepOptions[this.selectedStepIndex]
return item ? item.label : '请选择'
}
},
methods: {
getCurrentUserCode() {
const user = uni.getStorageSync('scanapp_user') || {}
return user.username || user.nickname || 'U001'
},
loadPdaMode() {
const userCode = this.getCurrentUserCode()
const settings = uni.getStorageSync(`scanapp_settings_${userCode}`)
this.pdaMode = settings?.pdaMode === true
},
handleMachineInputFocus() {
// #ifdef APP-PLUS
try {
const activity = plus.android.runtimeMainActivity()
const focusedView = activity.getWindow().getDecorView().findFocus()
if (focusedView) {
plus.android.invoke(focusedView, 'setShowSoftInputOnFocus', !this.pdaMode)
}
if (this.pdaMode) {
uni.hideKeyboard()
}
} catch (error) {
if (this.pdaMode) uni.hideKeyboard()
}
// #endif
},
getSelectionStorageKey() {
return `scanapp_entry_machine_selection_${this.currentUser.userCode}`
},
getSavedSelection() {
const selection = uni.getStorageSync(this.getSelectionStorageKey())
return selection && typeof selection === 'object' ? selection : {}
},
findOptionIndex(options, value) {
const index = options.findIndex((item) => String(item.value) === String(value))
return index >= 0 ? index : 0
},
saveSelection() {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
uni.setStorageSync(this.getSelectionStorageKey(), {
processId: processItem?.value || '',
stepId: stepItem?.value || ''
})
},
async loadPageData() {
this.currentUser.userCode = this.getCurrentUserCode()
const savedSelection = this.getSavedSelection()
try {
const [processOptions, statistics, recent] = await Promise.all([
getProcessOptions(),
getProductStatistics({ user_code: this.currentUser.userCode }),
getProductRecentRecords({ user_code: this.currentUser.userCode, limit: 5 })
])
this.processOptions = Array.isArray(processOptions) ? processOptions : []
this.selectedProcessIndex = this.findOptionIndex(this.processOptions, savedSelection.processId)
this.statistics = {
todayTotal: Number(statistics?.today_total || 0),
successTotal: Number(statistics?.success_total || 0),
failedTotal: Number(statistics?.failed_total || 0)
}
this.recentRecords = Array.isArray(recent?.items) ? recent.items.map((item, index) => ({
id: item.id || index,
processOrderNo: item.process_order_no || '-',
productCode: item.product_code || '-',
quantity: Number(item.quantity || 1),
userCode: item.user_code || this.currentUser.userCode
})) : []
if (this.processOptions.length > 0) {
await this.loadStepOptions(this.processOptions[this.selectedProcessIndex].value, savedSelection.stepId)
}
} catch (error) {
console.error('[scanapp] 整机页数据加载失败', error)
}
},
async loadStepOptions(processId, stepId = '') {
try {
const options = await getStepOptions(processId)
this.stepOptions = Array.isArray(options) ? options : []
this.selectedStepIndex = this.findOptionIndex(this.stepOptions, stepId)
await this.loadDataEntryConfig()
this.saveSelection()
} catch (error) {
this.stepOptions = []
this.selectedStepIndex = 0
this.basicSettings = {}
this.specificRules = []
console.error('[scanapp] 整机步骤加载失败', error)
}
},
async loadDataEntryConfig() {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
if (!processItem || !stepItem) {
this.basicSettings = {}
this.specificRules = []
return
}
try {
const config = await getDataEntryConfig({
process_id: processItem.value,
step_id: stepItem.value,
apply_mode: 'machine',
process_order_no: this.form.processOrderNo.trim(),
product_code: this.form.productCode.trim(),
user_code: this.currentUser.userCode.trim(),
order_no: this.form.processOrderNo.trim()
})
this.basicSettings = config?.basic_setting || {}
this.specificRules = Array.isArray(config?.specific_rules) ? config.specific_rules : []
} catch (error) {
this.basicSettings = {}
this.specificRules = []
console.error('[scanapp] 整机规则加载失败', error)
}
},
matchSpecificRule(rule, valueMap) {
const matchField = rule.match_field || 'process_order_no'
const value = String(valueMap[matchField] || '').trim()
const pattern = String(rule.rule_pattern || '').trim()
if (!value || !pattern) return false
try {
return new RegExp(pattern).test(value)
} catch (error) {
return false
}
},
validateSpecificRules() {
const context = {
order_no: this.form.processOrderNo.trim(),
process_order_no: this.form.processOrderNo.trim(),
product_code: this.form.productCode.trim(),
user_code: this.currentUser.userCode.trim()
}
const blockedRule = this.specificRules.find((rule) => {
if (rule.apply_mode && rule.apply_mode !== 'machine' && rule.apply_mode !== 'both') return false
return Number(rule.rule_type) === 2 && this.matchSpecificRule(rule, context)
})
if (blockedRule) {
uni.showToast({ title: blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交', icon: 'none' })
return false
}
return true
},
validatePattern(value, settingKey, label) {
const pattern = String(this.basicSettings[settingKey] || '').trim()
if (!pattern) return true
try {
if (!new RegExp(pattern).test(value)) {
uni.showToast({ title: `${label}格式不符合规则`, icon: 'none' })
return false
}
} catch (error) {
console.error(`[scanapp] ${label}规则格式错误`, error)
}
return true
},
showPicker(type) {
const list = type === 'process' ? this.processOptions : this.stepOptions
if (!list.length) {
uni.showToast({ title: '当前没有可选项', icon: 'none' })
return
}
uni.showActionSheet({
itemList: list.map((item) => item.label),
success: async (res) => {
if (type === 'process') {
this.selectedProcessIndex = res.tapIndex
await this.loadStepOptions(this.processOptions[res.tapIndex]?.value)
} else {
this.selectedStepIndex = res.tapIndex
await this.loadDataEntryConfig()
this.saveSelection()
}
}
})
},
scanToField(field) {
if (this.scanning || this.submitting) return
// #ifdef APP-PLUS
this.scanning = true
openCodeScan({
smartZoom: true,
vibrate: true,
scanRegion: getScanRegionSetting(),
success: async (result) => {
const value = String(result?.content || '').trim()
if (!value) return
this.form[field] = value
await this.loadDataEntryConfig()
},
fail: (error) => {
if (Number(error?.errCode) === 9021003) return
uni.showToast({
title: Number(error?.errCode) === 9021002 ? '未获得扫码权限' : '未识别到有效条码',
icon: 'none'
})
},
complete: () => {
this.scanning = false
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 Android App 中使用扫码功能', icon: 'none' })
// #endif
},
increaseQuantity() {
this.form.quantity = Number(this.form.quantity || 1) + 1
},
decreaseQuantity() {
this.form.quantity = Math.max(1, Number(this.form.quantity || 1) - 1)
},
validateForm() {
if (!this.form.processOrderNo.trim()) {
uni.showToast({ title: '请输入加工单号', icon: 'none' })
return false
}
if (!this.form.productCode.trim()) {
uni.showToast({ title: '请输入商品编码', icon: 'none' })
return false
}
if (!Number(this.form.quantity) || Number(this.form.quantity) < 1) {
uni.showToast({ title: '数量必须大于 0', icon: 'none' })
return false
}
if (!this.processOptions[this.selectedProcessIndex]) {
uni.showToast({ title: '请选择工序', icon: 'none' })
return false
}
if (!this.stepOptions[this.selectedStepIndex]) {
uni.showToast({ title: '请选择步骤', icon: 'none' })
return false
}
if (!this.validatePattern(this.form.processOrderNo.trim(), 'process_order_no_regex', '加工单号')) return false
const minLength = Number(this.basicSettings.product_code_min_length || 0)
if (minLength > 0 && this.form.productCode.trim().length < minLength) {
uni.showToast({ title: `商品编码长度不能少于 ${minLength} 位`, icon: 'none' })
return false
}
const maxLength = Number(this.basicSettings.product_code_max_length || 0)
if (maxLength > 0 && this.form.productCode.trim().length > maxLength) {
uni.showToast({ title: `商品编码长度不能超过 ${maxLength} 位`, icon: 'none' })
return false
}
if (!this.validatePattern(this.form.productCode.trim(), 'product_code_regex', '商品编码')) return false
if (!this.validateSpecificRules()) return false
return true
},
async submitEntry() {
await this.loadDataEntryConfig()
if (!this.validateForm()) return
this.submitting = true
try {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
await submitProductRecord({
process_order_no: this.form.processOrderNo.trim(),
product_code: this.form.productCode.trim(),
quantity: Number(this.form.quantity) || 1,
user_code: this.currentUser.userCode,
process_id: processItem?.value,
step_id: stepItem?.value,
scan_result: 1
})
uni.showToast({ title: '整机录入成功', icon: 'success' })
this.resetForm()
await this.loadPageData()
} catch (error) {
console.error('[scanapp] 整机录入失败', error)
} finally {
this.submitting = false
}
},
resetForm() {
this.form.processOrderNo = ''
this.form.productCode = ''
this.form.quantity = 1
this.loadDataEntryConfig()
}
}
}
</script>
<style lang="scss" scoped>
.entry-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #ebfff7 0%, #f4f6fb 38%, #f4f6fb 100%);
}
.hero-card,
.config-card,
.form-card,
.stats-card,
.record-card {
border: 1rpx solid rgba(255, 255, 255, 0.72);
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 42rpx rgba(23, 35, 61, 0.06);
}
.hero-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 34rpx 30rpx;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
}
.hero-title,
.hero-subtitle,
.card-title,
.card-subtitle,
.field-label,
.quantity-tip,
.chip-label,
.chip-value,
.stat-value,
.stat-label,
.record-main,
.record-sub {
display: block;
}
.hero-title {
color: #ffffff;
font-size: 38rpx;
font-weight: 800;
line-height: 1.35;
}
.hero-subtitle {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 22rpx;
line-height: 1.4;
}
.hero-icon {
display: flex;
align-items: center;
justify-content: center;
width: 86rpx;
height: 86rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.18);
}
.config-card,
.form-card,
.stats-card,
.record-card {
margin-top: 24rpx;
padding: 28rpx;
}
.card-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.card-subtitle {
margin-top: 6rpx;
color: #8a94a6;
font-size: 20rpx;
line-height: 1.4;
}
.chip-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
margin-top: 22rpx;
}
.select-chip {
padding: 20rpx;
border-radius: 22rpx;
background: #f8fbff;
}
.chip-label {
color: #8a94a6;
font-size: 20rpx;
}
.chip-value {
margin-top: 8rpx;
color: #17233d;
font-size: 26rpx;
font-weight: 700;
}
.field-card,
.quantity-card {
display: flex;
align-items: center;
width: 100%;
padding: 22rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 24rpx;
background: #f8fbff;
}
.primary-field {
border-color: rgba(53, 191, 163, 0.18);
background: #f5fffb;
}
.field-main {
flex: 1;
min-width: 0;
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6rpx;
}
.field-label {
margin-bottom: 6rpx;
color: #5f6b7a;
font-size: 22rpx;
font-weight: 600;
}
.quantity-tip {
color: #a0a7b5;
font-size: 20rpx;
}
.lock-pill {
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: #eef2f7;
color: #8a94a6;
font-size: 20rpx;
}
.lock-pill.is-active {
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
}
.scan-button {
display: flex;
align-items: center;
justify-content: center;
width: 76rpx;
height: 76rpx;
margin-left: 18rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
box-shadow: 0 12rpx 26rpx rgba(53, 191, 163, 0.2);
}
.quantity-card {
justify-content: space-between;
}
.quantity-control {
display: flex;
align-items: center;
overflow: hidden;
border-radius: 18rpx;
background: #ffffff;
}
.qty-button {
display: flex;
align-items: center;
justify-content: center;
width: 58rpx;
height: 58rpx;
color: #1677ff;
font-size: 28rpx;
font-weight: 700;
}
.qty-input {
width: 80rpx;
height: 58rpx;
color: #17233d;
font-size: 24rpx;
font-weight: 700;
text-align: center;
}
.action-row {
display: flex;
align-items: center;
margin-top: 28rpx;
}
.submit-secondary,
.submit-primary {
display: flex;
align-items: center;
justify-content: center;
height: 86rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 700;
line-height: 1;
}
.submit-secondary {
width: 176rpx;
background: #f2f4f8;
color: #5f6b7a;
flex: none;
}
.submit-primary {
margin-left: 14rpx;
flex: 1;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
color: #ffffff;
box-shadow: 0 14rpx 24rpx rgba(53, 191, 163, 0.18);
}
.submit-primary.is-loading {
opacity: 0.8;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.total-badge {
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(53, 191, 163, 0.1);
color: #19be6b;
font-size: 26rpx;
font-weight: 800;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.stat-item {
display: flex;
align-items: center;
justify-content: center;
min-height: 112rpx;
border-radius: 22rpx;
background: #f8fbff;
flex-direction: column;
}
.stat-value {
color: #17233d;
font-size: 32rpx;
font-weight: 800;
}
.stat-value.success {
color: #19be6b;
}
.stat-value.danger {
color: #e91f3f;
}
.stat-label {
margin-top: 8rpx;
color: #8a94a6;
font-size: 20rpx;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.record-item:last-child {
border-bottom: 0;
}
.record-main {
color: #17233d;
font-size: 26rpx;
font-weight: 700;
}
.record-sub {
margin-top: 6rpx;
color: #8a94a6;
font-size: 20rpx;
}
.record-status {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
font-size: 20rpx;
font-weight: 600;
}
.theme-dark.entry-page {
background: #151923;
}
.theme-dark .config-card,
.theme-dark .form-card,
.theme-dark .stats-card,
.theme-dark .record-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .select-chip,
.theme-dark .field-card,
.theme-dark .quantity-card,
.theme-dark .stat-item {
border-color: #364152;
background: #1c2431;
}
.theme-dark .quantity-control,
.theme-dark .qty-input {
background: #1c2431;
}
.theme-dark .qty-button,
.theme-dark .submit-secondary,
.theme-dark .lock-pill {
background: #293344;
}
.theme-dark .card-title,
.theme-dark .chip-value,
.theme-dark .field-label,
.theme-dark .record-main,
.theme-dark .stat-value,
.theme-dark .qty-input {
color: #f2f5fa;
}
.theme-dark .card-subtitle,
.theme-dark .chip-label,
.theme-dark .quantity-tip,
.theme-dark .record-sub,
.theme-dark .stat-label {
color: #aeb9ca;
}
.theme-dark .record-item {
border-color: #364152;
}
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>