Files
MrleeAdmin/frontend/app/pages/entry/order.vue
T

792 lines
19 KiB
Vue
Raw Normal View History

2026-08-31 11:33:59 +08:00
<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>