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

792 lines
19 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" :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="order" 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="form-title-row">
<view class="card-title">扫码录入</view>
<view class="scan-mode-inline">
<text class="scan-mode-title">连续扫码</text>
<u-switch v-model="continuousScan" size="34" active-color="#1677ff" inactive-color="#d6dbe5" />
</view>
</view>
<u-form :model="form" :border-bottom="false" label-position="top">
<u-form-item :border-bottom="false" prop="orderNo">
<view class="field-card primary-field">
<view class="field-main">
<text class="field-label">订单号</text>
<u-input
v-model="form.orderNo"
:input-mode="pdaMode ? 'none' : 'text'"
clearable
placeholder="请扫描或输入订单号"
placeholder-style="color: #a0a7b5;"
@focus="handleOrderInputFocus"
/>
</view>
<view class="scan-button" @tap="scanToField('orderNo')">
<u-icon name="scan" color="#ffffff" size="36" />
</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.orderNo }}</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 { getDataEntryConfig, getProcessOptions, getStepOptions, getOrderRecentRecords, getOrderStatistics, submitOrderRecord } 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,
continuousScan: false,
currentUser: {
userCode: 'U001'
},
basicSettings: {},
specificRules: [],
processOptions: [],
stepOptions: [],
selectedProcessIndex: 0,
selectedStepIndex: 0,
form: {
orderNo: ''
},
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
},
handleOrderInputFocus() {
// #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_order_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(),
getOrderStatistics({ user_code: this.currentUser.userCode }),
getOrderRecentRecords({ 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,
orderNo: item.order_no || '-',
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: 'order',
order_no: this.form.orderNo.trim(),
user_code: this.currentUser.userCode.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 || '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.orderNo.trim(),
user_code: this.currentUser.userCode.trim()
}
const blockedRule = this.specificRules.find((rule) => {
if (rule.apply_mode && rule.apply_mode !== 'order' && rule.apply_mode !== 'both') return false
if (Array.isArray(rule.step_ids) && rule.step_ids.length && !rule.step_ids.map(String).includes(String(this.stepOptions[this.selectedStepIndex]?.value))) 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()
}
}
})
},
startOrderScan() {
this.scanToField('orderNo')
},
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.submitEntry()
},
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
},
validateForm() {
if (!this.form.orderNo.trim()) {
uni.showToast({ title: '请输入订单号', 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
}
const minLength = Number(this.basicSettings.order_no_min_length || 0)
if (minLength > 0 && this.form.orderNo.trim().length < minLength) {
uni.showToast({ title: `订单号长度不能少于 ${minLength} 位`, icon: 'none' })
return false
}
if (!this.validatePattern(this.form.orderNo.trim(), 'order_no_regex', '订单号')) return false
if (!this.validateSpecificRules()) return false
return true
},
async submitEntry() {
if (this.submitting) return
this.submitting = true
try {
await this.loadDataEntryConfig()
if (!this.validateForm()) return
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
await submitOrderRecord({
order_no: this.form.orderNo.trim(),
quantity: 1,
user_code: this.currentUser.userCode,
process_id: processItem?.value,
step_id: stepItem?.value,
scan_result: 1
})
uni.showToast({ title: '订单录入成功', icon: 'success' })
this.form.orderNo = ''
await this.loadPageData()
if (this.continuousScan) {
setTimeout(() => {
this.startOrderScan()
}, 350)
}
} catch (error) {
console.error('[scanapp] 订单录入失败', error)
} finally {
this.submitting = false
}
},
resetForm() {
this.form.orderNo = ''
this.loadDataEntryConfig()
}
}
}
</script>
<style lang="scss" scoped>
.entry-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eaf4ff 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, #1677ff 0%, #35bfa3 100%);
}
.hero-title,
.hero-subtitle,
.card-title,
.card-subtitle,
.field-label,
.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;
}
.form-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.scan-mode-inline {
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
}
.scan-mode-title {
display: block;
margin-right: 22rpx;
color: #6b778c;
font-size: 22rpx;
font-weight: 600;
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 {
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(22, 119, 255, 0.18);
background: #f5f9ff;
}
.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;
}
.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, #1677ff 0%, #35bfa3 100%);
box-shadow: 0 12rpx 26rpx rgba(22, 119, 255, 0.2);
}
.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, #1677ff 0%, #35bfa3 100%);
color: #ffffff;
box-shadow: 0 14rpx 24rpx rgba(22, 119, 255, 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(22, 119, 255, 0.1);
color: #1677ff;
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 .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 {
color: #f2f5fa;
}
.theme-dark .card-subtitle,
.theme-dark .chip-label,
.theme-dark .scan-mode-title,
.theme-dark .record-sub,
.theme-dark .stat-label,
.theme-dark .field-label,
.theme-dark .quantity-tip {
color: #aeb9ca;
}
.theme-dark .record-item {
border-color: #364152;
}
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>