chore: initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.boxNo" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">集装箱拣货</text><text class="operation-desc">{{ entry.box.boxNo }} · {{ entry.box.orderNo }}</text></view><text class="history-status">已完成</text></view></view></view></view>
|
||||
</template>
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'
|
||||
export default { components: { PortalHeader }, data() { return { user: session.currentUser, overview: getPickingOverview() } }, computed: { completedEntries() { return this.overview.boxes.map((box, index) => ({ box, index })).filter((entry) => this.overview.completedBoxes[entry.index]) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() } }
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page home-page">
|
||||
<PortalHeader ref="portalHeader" title="首页" :user="user" menu @menu-change="menuOpen = $event" />
|
||||
<view class="home-menu">
|
||||
<view v-for="group in groups" :key="group.key" class="home-group" :class="{ 'home-group-disabled': !group.available }">
|
||||
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text></view>
|
||||
<view class="home-operation-grid">
|
||||
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
|
||||
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
|
||||
<text class="home-operation-name">{{ operation.name }}</text>
|
||||
<text v-if="operation.available" class="home-operation-pending">{{ overview.pendingBoxCount }} 箱待拣</text>
|
||||
<text v-else class="home-operation-unavailable">未开放</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import { MODULES } from '@/data/demo-data.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { getPickingOverview } from '@/stores/picking.js'
|
||||
|
||||
const ICON_TYPES = {
|
||||
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
|
||||
'集装箱拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
|
||||
'库存盘点': 'list', '商品移库': 'loop', '库存调整': 'compose',
|
||||
'商品查询': 'search', '库位查询': 'location', '任务查询': 'flag'
|
||||
}
|
||||
|
||||
const HOME_GROUPS = MODULES.map((module) => ({
|
||||
...module,
|
||||
name: module.key === 'inventory' ? '库内作业' : module.name,
|
||||
available: module.operations.some((operation) => operation.available),
|
||||
operations: module.operations.map((operation) => ({ ...operation, iconType: ICON_TYPES[operation.name] || 'circle' }))
|
||||
}))
|
||||
|
||||
export default {
|
||||
components: { PortalHeader },
|
||||
data() { return { user: session.currentUser, groups: HOME_GROUPS, overview: getPickingOverview(), menuOpen: false } },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
|
||||
onBackPress() {
|
||||
if (!this.menuOpen) return false
|
||||
this.$refs.portalHeader.closeMenu()
|
||||
return true
|
||||
},
|
||||
methods: {
|
||||
openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<view class="page-shell login-page">
|
||||
<view class="login-brand">
|
||||
<view class="login-logo-icon" aria-hidden="true"><view class="logo-grid-line logo-grid-line-horizontal"></view><view class="logo-grid-line logo-grid-line-vertical"></view></view>
|
||||
<text class="login-logo-title">柏悦物流 · PDA作业端</text><text class="login-logo-sub">仓库现场作业系统</text>
|
||||
</view>
|
||||
<view class="login-card">
|
||||
<view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
|
||||
<view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
|
||||
<view class="login-field"><text class="login-field-label">作业仓库</text><picker aria-label="作业仓库" :range="warehouses" :value="warehouseIndex" @change="changeWarehouse"><view class="login-field-select"><text>{{ form.warehouse }}</text><text class="select-chevron">⌄</text></view></picker></view>
|
||||
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
|
||||
<button class="login-btn" @tap="login">登录</button>
|
||||
<view class="login-accounts"><text class="login-accounts-title">快捷登录(演示账号)</text><view class="login-quick-btns"><button v-for="account in accounts" :key="account.id" class="login-quick-btn" @tap="enter(account)"><text class="login-quick-btn-name">{{ account.id }} {{ account.name }}</text><text class="login-quick-btn-role"> · {{ account.role }}</text></button></view></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DEMO_ACCOUNTS, WAREHOUSES } from '@/data/demo-data.js'
|
||||
import { session, signIn } from '@/stores/session.js'
|
||||
export default {
|
||||
data() { return { accounts: DEMO_ACCOUNTS, warehouses: WAREHOUSES, form: { userId: '', password: '', warehouse: WAREHOUSES[0] }, error: '' } },
|
||||
computed: { warehouseIndex() { return Math.max(0, this.warehouses.indexOf(this.form.warehouse)) } },
|
||||
onLoad() { if (session.currentUser) uni.reLaunch({ url: '/pages/home/home' }) },
|
||||
methods: {
|
||||
changeWarehouse(event) { this.form.warehouse = this.warehouses[Number(event.detail.value)] || this.warehouses[0] },
|
||||
login() {
|
||||
const userId = this.form.userId.trim()
|
||||
if (!userId) return void (this.error = '请输入工号')
|
||||
if (!this.form.password) return void (this.error = '请输入密码')
|
||||
const account = this.accounts.find((entry) => entry.id === userId)
|
||||
if (!account || account.password !== this.form.password) return void (this.error = '工号或密码错误')
|
||||
this.enter(account)
|
||||
},
|
||||
enter(account) { signIn(account, this.form.warehouse); uni.reLaunch({ url: '/pages/home/home' }) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view v-if="user && currentModule" class="page-shell portal-page">
|
||||
<PortalHeader title="业务模块" :user="user" />
|
||||
<view class="portal-content">
|
||||
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view></view>
|
||||
<text class="group-title">业务功能</text>
|
||||
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-count">{{ overview.pendingBoxCount }}</text><text v-else class="operation-prototype">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
|
||||
<view class="module-empty-note">第一版本仅开放“出库 → 集装箱拣货”,其他业务将在后续版本接入。</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import { MODULES } from '@/data/demo-data.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { getPickingOverview } from '@/stores/picking.js'
|
||||
export default {
|
||||
components: { PortalHeader },
|
||||
data() { return { user: session.currentUser, moduleKey: 'outbound', overview: getPickingOverview() } },
|
||||
computed: { currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] } },
|
||||
onLoad(options) { this.moduleKey = options.key || 'outbound' },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
|
||||
methods: {
|
||||
openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page">
|
||||
<PortalHeader :title="operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
||||
<view class="portal-content operation-page">
|
||||
<view class="operation-page-title-row"><view><text class="operation-page-title">{{ operationName }}</text><text class="operation-page-subtitle">{{ prototype.subtitle }}</text></view><text class="prototype-badge">静态原型</text></view>
|
||||
<view class="operation-scan-card"><text class="operation-scan-label">{{ prototype.scanLabel }}</text><view class="operation-scan-row"><input class="operation-scan-input" :aria-label="prototype.scanLabel" :placeholder="prototype.scanPlaceholder" confirm-type="search" @confirm="prototypeScan" /><button class="operation-scan-button" @tap="prototypeScan">扫码</button></view><text class="operation-scan-tip">支持硬件扫描头连续扫描,也可手工输入单号</text></view>
|
||||
<view class="operation-metrics"><view v-for="metric in prototype.metrics" :key="metric.label" class="operation-metric"><text class="operation-metric-value" :class="metric.tone">{{ metric.value }}</text><text class="operation-metric-label">{{ metric.label }}</text></view></view>
|
||||
<view class="task-toolbar"><text class="group-title operation-group-title">任务列表</text><button class="filter-button" @tap="showToast('筛选面板为静态原型', 'warning')">筛选</button></view>
|
||||
<view class="prototype-tabs"><button v-for="item in tabs" :key="item" class="prototype-tab" :class="{ active: tab === item }" @tap="tab = item">{{ item }}</button></view>
|
||||
<view v-if="filteredTasks.length" class="prototype-task-list"><view v-for="task in filteredTasks" :key="task.code" class="prototype-task-card"><view class="prototype-task-head"><text class="prototype-task-code">{{ task.code }}</text><text class="prototype-task-status" :class="task.tone">{{ task.status }}</text></view><text class="prototype-task-title">{{ task.title }}</text><view class="prototype-task-meta"><text>{{ task.meta }}</text><text>{{ task.location }}</text></view><view class="prototype-task-foot"><text class="prototype-task-qty">{{ task.quantity }}</text><button class="prototype-task-action" @tap="showToast(`${task.action}:${task.code}`, 'success')">{{ task.action }}</button></view></view></view>
|
||||
<view v-else class="operation-empty"><text class="operation-empty-icon">✓</text><text class="history-empty-title">暂无{{ tab }}任务</text><text class="history-empty-desc">切换其他状态查看演示数据</text></view>
|
||||
</view>
|
||||
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import { MODULES, getOperationPrototype } from '@/data/demo-data.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
export default {
|
||||
components: { PortalHeader },
|
||||
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '任务查询', tabs: ['待处理', '进行中', '已完成'], tab: '待处理', toast: { visible: false, message: '', type: '' } } },
|
||||
computed: { currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] }, prototype() { return getOperationPrototype(this.operationName) }, filteredTasks() { return this.prototype.tasks.filter((task) => task.status === this.tab) } },
|
||||
onLoad(options) { this.moduleKey = options.module || 'query'; this.operationName = decodeURIComponent(options.name || '任务查询') },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser },
|
||||
beforeUnmount() { clearTimeout(this._toastTimer) },
|
||||
methods: { prototypeScan() { this.showToast(`${this.operationName}扫码为静态演示`, 'success') }, showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) } }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,707 @@
|
||||
<template>
|
||||
<view v-if="currentUser" class="page-shell">
|
||||
<view class="work-page" :aria-hidden="activeOverlay ? 'true' : 'false'">
|
||||
<view class="app-header">
|
||||
<view class="header-top">
|
||||
<button class="header-back" aria-label="返回上一页" @tap="leavePicking">‹</button>
|
||||
<text class="header-title">集装箱拣货</text>
|
||||
<view class="header-actions">
|
||||
<text class="header-badge">{{ currentUser.warehouse }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="work-content">
|
||||
<view class="box-select-wrap">
|
||||
<button class="box-select-btn" @tap="toggleBoxDropdown">
|
||||
<text>{{ currentBox.boxNo }} · 订单 {{ currentBox.orderNo }}</text>
|
||||
<text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text>
|
||||
</button>
|
||||
<view v-if="boxDropdownOpen" class="box-dropdown">
|
||||
<view
|
||||
v-for="(box, index) in boxes"
|
||||
:key="box.boxNo"
|
||||
class="box-option"
|
||||
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
|
||||
@tap="requestBoxSwitch(index)"
|
||||
>
|
||||
<view><text class="box-option-number">{{ box.boxNo }}</text><text class="box-option-order">{{ box.orderNo }}</text></view>
|
||||
<text v-if="completedBoxes[index]" class="box-option-state">已完成</text>
|
||||
<text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scan-section">
|
||||
<view class="scan-row">
|
||||
<ScanTargetInput v-model="scanValue" name="sku" label="SKU 扫码框" placeholder="扫描或输入 M001" :active="activeScanField === 'sku'" @activate="setScanTarget" @submit="handleManualSubmit" />
|
||||
</view>
|
||||
<text class="scan-hardware-tip">支持硬件广播扫码,也可点击输入后按“完成”确认</text>
|
||||
<view v-if="lastScanResult" class="last-scan" :class="lastScanResult.type" role="status">
|
||||
<view class="last-scan-icon">{{ lastScanResult.type === 'success' ? '✓' : '!' }}</view>
|
||||
<view class="last-scan-content">
|
||||
<text class="last-scan-label">最近扫码 · {{ lastScanResult.title }}</text>
|
||||
<text class="last-scan-sku">{{ lastScanResult.sku }}</text>
|
||||
<text class="last-scan-detail">{{ lastScanResult.detail }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="scanHistory.length > 1" class="scan-history">
|
||||
<text v-for="entry in scanHistory.slice(1, 3)" :key="entry.id" class="scan-history-item">{{ entry.sku }} · {{ entry.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-section">
|
||||
<view class="progress-header">
|
||||
<text class="progress-title">拣货进度</text>
|
||||
<view class="progress-actions">
|
||||
<view class="progress-stats">
|
||||
<template v-if="summary.partial > 0">
|
||||
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
|
||||
<text> · 部分拣 </text><text class="progress-number">{{ summary.partial }}</text>
|
||||
<text> / {{ summary.total }} 件</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
|
||||
<text> / {{ summary.total }} 件</text>
|
||||
</template>
|
||||
</view>
|
||||
<button class="box-tasks-btn" @tap="openBoxList('pending')">箱任务 {{ completedCount }}/{{ boxes.length }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-bar-bg">
|
||||
<view class="progress-bar-fill" :style="{ transform: 'scaleX(' + summary.progress / 100 + ')' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pick-list">
|
||||
<view v-if="orderedItems.length === 0" class="empty-state">
|
||||
<text class="empty-state-icon">□</text>
|
||||
<text class="empty-state-text">该箱号暂无拣货任务</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in orderedItems"
|
||||
:key="item.id"
|
||||
class="pick-item"
|
||||
:class="itemState(item)"
|
||||
>
|
||||
<view class="pick-item-row1">
|
||||
<text class="pick-item-sku">{{ item.sku }}</text>
|
||||
<view class="pick-item-meta">
|
||||
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
|
||||
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
|
||||
部分拣 {{ partialQuantities[item.id] || 0 }}/{{ item.qty }}
|
||||
</text>
|
||||
<text v-else-if="itemState(item) === ITEM_STATUS.SKIPPED" class="pick-status-tag tag-skipped">已跳拣</text>
|
||||
<view class="pick-item-qty">
|
||||
<text>需拣 </text><text class="qty-number">{{ item.qty }}</text><text> {{ item.unit }}</text>
|
||||
<template v-if="itemState(item) === ITEM_STATUS.PARTIAL">
|
||||
<text> · 实拣 </text><text class="actual-number">{{ partialQuantities[item.id] || 0 }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pick-item-row2">
|
||||
<text class="pick-item-desc">{{ itemDescription(item) }}</text>
|
||||
<text class="pick-item-location">{{ item.location }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
|
||||
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认拣出</button>
|
||||
<button class="pick-btn pick-btn-partial" @tap="openPartial(item.id)">部分拣货</button>
|
||||
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||||
</view>
|
||||
<view v-else-if="!completedBoxes[currentBoxIndex]" class="pick-item-actions processed-actions">
|
||||
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="submit-section">
|
||||
<view class="submit-info">
|
||||
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 件待拣</text></view>
|
||||
<text class="submit-status-hint">{{ submitStatusHint }}</text>
|
||||
</view>
|
||||
<button class="submit-btn" aria-label="提交拣货完成" :disabled="completedBoxes[currentBoxIndex] || !summary.canSubmit" @tap="submitPicking">
|
||||
{{ submitButtonText }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<uni-popup ref="boxListPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('boxList', $event)">
|
||||
<view class="modal-sheet box-list-modal" role="dialog" aria-modal="true" :aria-label="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @tap.stop>
|
||||
<PopupHeader :title="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @close="closeOverlay" />
|
||||
<view v-if="visibleBoxList.length === 0" class="modal-empty">
|
||||
{{ boxListType === 'done' ? '暂无已拣完成的集装箱' : '所有集装箱已拣完成' }}
|
||||
</view>
|
||||
<view
|
||||
v-for="entry in visibleBoxList"
|
||||
:key="entry.box.boxNo"
|
||||
class="box-list-item"
|
||||
:class="boxListType === 'done' ? 'box-list-item-done' : 'box-list-item-pending'"
|
||||
@tap="selectBoxFromList(entry)"
|
||||
>
|
||||
<view class="box-list-info">
|
||||
<text class="box-list-number">{{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text>
|
||||
<text class="box-list-detail">{{ boxListDetail(entry) }}</text>
|
||||
</view>
|
||||
<text class="box-list-tag" :class="boxListType === 'done' ? 'box-list-tag-done' : 'box-list-tag-pending'">
|
||||
{{ boxListType === 'done' ? '已完成 ✓' : (entry.index === currentBoxIndex ? '拣货中' : '待拣') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<uni-popup ref="skipPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('skip', $event)">
|
||||
<view class="modal-sheet skip-modal" role="dialog" aria-modal="true" aria-label="选择跳拣原因" @tap.stop>
|
||||
<PopupHeader title="选择跳拣原因" @close="closeOverlay" />
|
||||
<view class="skip-reasons">
|
||||
<button
|
||||
v-for="reason in skipReasons"
|
||||
:key="reason"
|
||||
class="skip-reason-btn"
|
||||
:class="{ selected: selectedSkipReason === reason }"
|
||||
@tap="selectedSkipReason = reason"
|
||||
>
|
||||
{{ reason }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="modal-actions">
|
||||
<button class="modal-cancel-btn" @tap="closeOverlay">取消</button>
|
||||
<button class="modal-confirm-btn skip-confirm-btn" @tap="confirmSkip">确认跳拣</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<uni-popup ref="partialPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('partial', $event)">
|
||||
<view class="modal-sheet partial-modal" role="dialog" aria-modal="true" aria-label="部分拣货" @tap.stop>
|
||||
<PopupHeader title="部分拣货" @close="closeOverlay" />
|
||||
<view v-if="partialTargetItem" class="partial-info">
|
||||
<text class="partial-info-sku">{{ partialTargetItem.sku }}</text>
|
||||
<text class="partial-info-desc">{{ partialTargetItem.desc }}</text>
|
||||
<view class="partial-info-qty">需拣总量:<text>{{ partialTargetItem.qty }}</text> {{ partialTargetItem.unit }}</view>
|
||||
</view>
|
||||
<view class="partial-input-row">
|
||||
<text class="partial-input-label">实际拣出</text>
|
||||
<input
|
||||
v-model="partialInput"
|
||||
class="partial-input"
|
||||
aria-label="实际拣出数量"
|
||||
type="number"
|
||||
:focus="partialFocused"
|
||||
confirm-type="done"
|
||||
@confirm="confirmPartial"
|
||||
/>
|
||||
<text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
|
||||
</view>
|
||||
<view class="partial-quick-row">
|
||||
<button
|
||||
v-for="option in partialQuickOptions"
|
||||
:key="option.label"
|
||||
class="partial-quick-btn"
|
||||
@tap="setPartialInput(option.value)"
|
||||
>
|
||||
{{ option.label }} ({{ option.value }})
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="partialValidationMessage" class="partial-validation" role="alert">{{ partialValidationMessage }}</text>
|
||||
<view class="modal-actions">
|
||||
<button class="modal-cancel-btn" @tap="closeOverlay">取消</button>
|
||||
<button class="modal-confirm-btn" :disabled="!partialInputValid" @tap="confirmPartial">确认部分拣货</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<uni-popup ref="switchBoxConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('switchBoxConfirm', $event)">
|
||||
<view class="modal-sheet decision-modal" role="dialog" aria-modal="true" aria-label="保存当前拣货进度" @tap.stop>
|
||||
<PopupHeader title="切换前保存当前进度" @close="closeOverlay" />
|
||||
<text class="decision-desc">当前箱已处理 {{ summary.processed }} 件,还有 {{ summary.pending }} 件待拣。进度会保存为本机演示草稿。</text>
|
||||
<view class="decision-summary">
|
||||
<view><text class="decision-number">{{ summary.processed }}</text><text>已处理</text></view>
|
||||
<view><text class="decision-number warning">{{ summary.pending }}</text><text>待拣</text></view>
|
||||
</view>
|
||||
<view class="modal-actions">
|
||||
<button class="modal-cancel-btn" @tap="closeOverlay">继续当前箱</button>
|
||||
<button class="modal-confirm-btn" @tap="confirmBoxSwitch">保存并切换</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
|
||||
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
|
||||
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
||||
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
|
||||
<view class="pending-sku-list">
|
||||
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.sku }} · {{ item.location }}</text>
|
||||
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
||||
</view>
|
||||
<text class="decision-label">选择未完成原因</text>
|
||||
<view class="incomplete-reasons">
|
||||
<button v-for="reason in incompleteReasons" :key="reason" class="incomplete-reason" :class="{ selected: selectedIncompleteReason === reason }" @tap="selectedIncompleteReason = reason">{{ reason }}</button>
|
||||
</view>
|
||||
<view class="modal-actions submit-decision-actions">
|
||||
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
|
||||
<button class="modal-confirm-btn danger-confirm" :disabled="!selectedIncompleteReason" @tap="confirmIncompleteSubmit">确认未拣并提交</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<uni-popup ref="completePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('complete', $event)">
|
||||
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop>
|
||||
<PopupHeader title="拣货完成" @close="closeOverlay" />
|
||||
<view class="complete-icon">✓</view>
|
||||
<text class="complete-desc">箱号 {{ currentBox.boxNo }} · 订单 {{ currentBox.orderNo }}</text>
|
||||
<view class="complete-stats">
|
||||
<view class="complete-stat">
|
||||
<text class="complete-stat-num green">{{ completionSummary.picked }}</text>
|
||||
<text class="complete-stat-label">已拣出</text>
|
||||
</view>
|
||||
<view class="complete-stat">
|
||||
<text class="complete-stat-num blue">{{ completionSummary.partial }}</text>
|
||||
<text class="complete-stat-label">部分拣货</text>
|
||||
</view>
|
||||
<view class="complete-stat">
|
||||
<text class="complete-stat-num gray">{{ completionSummary.skipped }}</text>
|
||||
<text class="complete-stat-label">跳拣/未拣</text>
|
||||
</view>
|
||||
<view v-if="completionSummary.autoSkipped > 0" class="complete-stat">
|
||||
<text class="complete-stat-num red">{{ completionSummary.autoSkipped }}</text>
|
||||
<text class="complete-stat-label">自动未拣</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="complete-btns">
|
||||
<button class="complete-btn-back" @tap="closeOverlay">返回查看</button>
|
||||
<button class="complete-btn-next" @tap="nextBox">{{ nextBoxButtonText }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<uni-popup ref="allCompletePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('allComplete', $event)">
|
||||
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="全部箱号已完成" @tap.stop>
|
||||
<PopupHeader title="全部箱号已完成" @close="closeOverlay" />
|
||||
<view class="complete-icon">✓</view>
|
||||
<text class="complete-desc">本班次演示任务 {{ boxes.length }} 箱已全部处理</text>
|
||||
<button class="all-complete-btn" @tap="leavePicking">返回工作台</button>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ITEM_STATUS, classifyScan, getPickSummary, submitStates } from '@/utils/picking-state.js'
|
||||
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { getPickingSession, savePickingSession } from '@/stores/picking.js'
|
||||
import { navigateBackOr } from '@/utils/navigation.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import PopupHeader from '@/components/PopupHeader.vue'
|
||||
|
||||
export default {
|
||||
components: { PopupHeader, ScanTargetInput },
|
||||
data() {
|
||||
return {
|
||||
ITEM_STATUS,
|
||||
currentUser: session.currentUser,
|
||||
skipReasons: SKIP_REASONS,
|
||||
incompleteReasons: INCOMPLETE_REASONS,
|
||||
...getPickingSession(),
|
||||
activeScanField: 'sku',
|
||||
partialFocused: false,
|
||||
boxDropdownOpen: false,
|
||||
activeOverlay: '',
|
||||
boxListType: 'pending',
|
||||
skipTargetId: null,
|
||||
selectedSkipReason: '',
|
||||
partialTargetId: null,
|
||||
partialInput: '',
|
||||
switchTargetIndex: null,
|
||||
selectedIncompleteReason: '',
|
||||
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
|
||||
toast: { visible: false, message: '', type: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentBox() {
|
||||
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] }
|
||||
},
|
||||
summary() {
|
||||
return getPickSummary(this.currentBox.items, this.itemStates)
|
||||
},
|
||||
orderedItems() {
|
||||
const pending = []
|
||||
const processed = []
|
||||
this.currentBox.items.forEach((item) => {
|
||||
if (this.itemState(item) === ITEM_STATUS.PENDING) pending.push(item)
|
||||
else processed.push(item)
|
||||
})
|
||||
return pending.concat(processed)
|
||||
},
|
||||
pendingItems() {
|
||||
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
|
||||
},
|
||||
lastScanResult() {
|
||||
return this.scanHistory[0] || null
|
||||
},
|
||||
completedCount() {
|
||||
return Object.keys(this.completedBoxes).filter((key) => this.completedBoxes[key]).length
|
||||
},
|
||||
pendingBoxCount() {
|
||||
return this.boxes.length - this.completedCount
|
||||
},
|
||||
partialTargetItem() {
|
||||
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
|
||||
},
|
||||
partialQuickOptions() {
|
||||
if (!this.partialTargetItem) return []
|
||||
const quantity = this.partialTargetItem.qty
|
||||
return [
|
||||
{ label: '1/4', value: Math.floor(quantity * 0.25) },
|
||||
{ label: '1/2', value: Math.floor(quantity * 0.5) },
|
||||
{ label: '3/4', value: Math.floor(quantity * 0.75) },
|
||||
{ label: '差1', value: Math.max(1, quantity - 1) }
|
||||
]
|
||||
},
|
||||
partialInputValid() {
|
||||
if (!this.partialTargetItem) return false
|
||||
const value = parseInt(this.partialInput, 10)
|
||||
return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty
|
||||
},
|
||||
partialValidationMessage() {
|
||||
if (!this.partialInput || !this.partialTargetItem) return ''
|
||||
const value = parseInt(this.partialInput, 10)
|
||||
if (!Number.isInteger(value) || value <= 0) return '请输入大于 0 的整数'
|
||||
if (value >= this.partialTargetItem.qty) return `部分拣货必须少于 ${this.partialTargetItem.qty},整单请返回后点击“确认拣出”`
|
||||
return ''
|
||||
},
|
||||
submitButtonText() {
|
||||
return this.completedBoxes[this.currentBoxIndex] ? '该箱已完成' : this.summary.buttonText
|
||||
},
|
||||
submitStatusHint() {
|
||||
if (this.completedBoxes[this.currentBoxIndex]) return '本箱任务已完成'
|
||||
if (!this.summary.canSubmit) return '请先处理至少一件'
|
||||
if (this.summary.pending > 0) return '提交时需选择原因'
|
||||
return `已处理 ${this.summary.processed}/${this.summary.total}`
|
||||
},
|
||||
nextBoxButtonText() {
|
||||
return this.completedCount === this.boxes.length ? '查看全部完成' : '下一箱'
|
||||
},
|
||||
visibleBoxList() {
|
||||
return this.boxes
|
||||
.map((box, index) => ({ box, index }))
|
||||
.filter((entry) => this.boxListType === 'done'
|
||||
? Boolean(this.completedBoxes[entry.index])
|
||||
: !this.completedBoxes[entry.index])
|
||||
}
|
||||
},
|
||||
onLoad() { requireSession() },
|
||||
onShow() {
|
||||
if (!requireSession()) return
|
||||
this.startHardwareScanner()
|
||||
},
|
||||
onHide() { this.stopHardwareScanner() },
|
||||
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
||||
beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
|
||||
onBackPress() {
|
||||
if (this.activeOverlay) { this.closeOverlay(); return true }
|
||||
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
|
||||
this.persistSession()
|
||||
return false
|
||||
},
|
||||
methods: {
|
||||
handleOverlayChange(name, event) {
|
||||
if (!event.show && this.activeOverlay === name) this.resetOverlayState()
|
||||
},
|
||||
startHardwareScanner() {
|
||||
this.stopHardwareScanner()
|
||||
this._stopScanBroadcast = startScanBroadcast({
|
||||
onScan: (value) => this.handleHardwareScan(value),
|
||||
onError: () => this.showToast('硬件扫码接收器启动失败,请检查扫描服务', 'warning')
|
||||
})
|
||||
},
|
||||
stopHardwareScanner() {
|
||||
if (typeof this._stopScanBroadcast !== 'function') return
|
||||
this._stopScanBroadcast()
|
||||
this._stopScanBroadcast = null
|
||||
},
|
||||
handleHardwareScan(value) {
|
||||
if (this.activeOverlay || this.boxDropdownOpen) return
|
||||
if (this.activeScanField !== 'sku') return
|
||||
this.scanValue = value
|
||||
this.processScannedValue(value, 'broadcast')
|
||||
},
|
||||
handleManualSubmit(value) {
|
||||
if (this.activeOverlay || this.boxDropdownOpen) return
|
||||
const normalizedValue = String(value || '').trim().toUpperCase()
|
||||
this.scanValue = normalizedValue
|
||||
this.processScannedValue(normalizedValue, 'manual')
|
||||
},
|
||||
setScanTarget(fieldName) { this.activeScanField = fieldName },
|
||||
openOverlay(name) {
|
||||
this.activeOverlay = name
|
||||
this.$nextTick(() => {
|
||||
const popup = this.$refs[`${name}Popup`]
|
||||
if (popup) popup.open()
|
||||
})
|
||||
},
|
||||
persistSession() {
|
||||
this.saveCurrentBoxDraft()
|
||||
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory })
|
||||
},
|
||||
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
|
||||
clearTimers() { clearTimeout(this._toastTimer) },
|
||||
scrollToTop() { this.$nextTick(() => { if (typeof uni !== 'undefined' && typeof uni.pageScrollTo === 'function') uni.pageScrollTo({ scrollTop: 0, duration: 0 }) }) },
|
||||
saveCurrentBoxDraft() {
|
||||
if (!this.currentBox.boxNo) return
|
||||
this.boxDrafts = {
|
||||
...this.boxDrafts,
|
||||
[this.currentBoxIndex]: {
|
||||
itemStates: { ...this.itemStates },
|
||||
partialQuantities: { ...this.partialQuantities },
|
||||
scanHistory: this.scanHistory.map((entry) => ({ ...entry }))
|
||||
}
|
||||
}
|
||||
},
|
||||
loadBoxState(index) {
|
||||
const draft = this.boxDrafts[index]
|
||||
if (draft) {
|
||||
this.itemStates = { ...draft.itemStates }
|
||||
this.partialQuantities = { ...draft.partialQuantities }
|
||||
this.scanHistory = draft.scanHistory.map((entry) => ({ ...entry }))
|
||||
} else {
|
||||
this.itemStates = createItemStates(this.currentBox.items)
|
||||
this.partialQuantities = {}
|
||||
this.scanHistory = []
|
||||
}
|
||||
this.scanValue = ''
|
||||
},
|
||||
toggleBoxDropdown() {
|
||||
this.boxDropdownOpen = !this.boxDropdownOpen
|
||||
},
|
||||
requestBoxSwitch(index) {
|
||||
if (index === this.currentBoxIndex) {
|
||||
this.boxDropdownOpen = false
|
||||
return
|
||||
}
|
||||
this.boxDropdownOpen = false
|
||||
if (!this.completedBoxes[this.currentBoxIndex] && this.summary.processed > 0 && this.summary.pending > 0) {
|
||||
this.switchTargetIndex = index
|
||||
this.openOverlay('switchBoxConfirm')
|
||||
return
|
||||
}
|
||||
this.selectBox(index)
|
||||
},
|
||||
confirmBoxSwitch() {
|
||||
if (this.switchTargetIndex === null) return
|
||||
const targetIndex = this.switchTargetIndex
|
||||
this.closeOverlay(false)
|
||||
this.selectBox(targetIndex)
|
||||
},
|
||||
selectBox(index) {
|
||||
this.saveCurrentBoxDraft()
|
||||
this.currentBoxIndex = index
|
||||
this.boxDropdownOpen = false
|
||||
this.loadBoxState(index)
|
||||
this.scrollToTop()
|
||||
this.showToast(`切换至 ${this.currentBox.boxNo}`, 'success')
|
||||
},
|
||||
itemState(item) {
|
||||
return this.itemStates[item.id] || ITEM_STATUS.PENDING
|
||||
},
|
||||
hasBoxDraft(index) {
|
||||
const draft = this.boxDrafts[index]
|
||||
return Boolean(draft && Object.values(draft.itemStates).some((status) => status !== ITEM_STATUS.PENDING))
|
||||
},
|
||||
itemDescription(item) {
|
||||
const reason = this.itemState(item) === ITEM_STATUS.SKIPPED && item.skipReason
|
||||
? ` · ${item.skipReason}`
|
||||
: ''
|
||||
return `${item.desc}${reason}`
|
||||
},
|
||||
processScannedValue(scanValue, source = 'broadcast') {
|
||||
const value = scanValue.trim()
|
||||
if (!value) {
|
||||
const manual = source === 'manual'
|
||||
playErrorSound()
|
||||
this.recordScanResult('error', manual ? '未输入商品编码' : '未读取到条码', manual ? '输入内容为空' : '广播值为空', manual ? '请输入 SKU 后按“完成”' : '请重新扫描并检查设备广播配置')
|
||||
this.showToast(manual ? '请输入商品编码' : '广播扫码结果为空', 'warning')
|
||||
return
|
||||
}
|
||||
const result = classifyScan(this.currentBox.items, this.itemStates, value)
|
||||
if (result.type === 'processed') {
|
||||
playErrorSound()
|
||||
const labels = {
|
||||
[ITEM_STATUS.PICKED]: '已拣出',
|
||||
[ITEM_STATUS.PARTIAL]: '已部分拣货',
|
||||
[ITEM_STATUS.SKIPPED]: '已跳拣'
|
||||
}
|
||||
this.recordScanResult('warning', '重复扫描', value, `该 SKU ${labels[result.status]},无需重复处理`)
|
||||
this.showToast(`该SKU${labels[result.status]}:${value}`, 'warning')
|
||||
return
|
||||
}
|
||||
if (result.type === 'unknown') {
|
||||
playErrorSound()
|
||||
this.recordScanResult('error', '当前箱无此商品', value, '请核对箱号、SKU 或转入异常处理')
|
||||
this.showToast(`未找到匹配的待拣SKU:${value}`, 'error')
|
||||
return
|
||||
}
|
||||
playSuccessSound()
|
||||
this.pickItem(result.item.id, 'scan')
|
||||
},
|
||||
pickItem(id, source = 'manual') {
|
||||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||||
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED }
|
||||
if (source === 'scan' && item) {
|
||||
this.recordScanResult('success', '扫描成功', item.sku, `${item.location} · 已拣 ${item.qty} ${item.unit}`)
|
||||
}
|
||||
this.showToast(item ? `已拣出 ${item.sku}` : '已确认拣出', 'success')
|
||||
},
|
||||
recordScanResult(type, title, sku, detail) {
|
||||
const entry = { id: `${Date.now()}-${this.scanHistory.length}-${sku}`, type, title, sku, detail }
|
||||
this.scanHistory = [entry, ...this.scanHistory].slice(0, 3)
|
||||
},
|
||||
undoItem(id) {
|
||||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||||
if (item) item.skipReason = ''
|
||||
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PENDING }
|
||||
const nextPartialQuantities = { ...this.partialQuantities }
|
||||
delete nextPartialQuantities[id]
|
||||
this.partialQuantities = nextPartialQuantities
|
||||
this.showToast('已撤销操作', 'success')
|
||||
},
|
||||
openSkip(id) {
|
||||
this.skipTargetId = id
|
||||
this.selectedSkipReason = ''
|
||||
this.openOverlay('skip')
|
||||
},
|
||||
confirmSkip() {
|
||||
if (!this.selectedSkipReason) {
|
||||
this.showToast('请先选择跳拣原因', 'warning')
|
||||
return
|
||||
}
|
||||
const item = this.currentBox.items.find((entry) => entry.id === this.skipTargetId)
|
||||
if (item) item.skipReason = this.selectedSkipReason
|
||||
this.itemStates = { ...this.itemStates, [this.skipTargetId]: ITEM_STATUS.SKIPPED }
|
||||
const reason = this.selectedSkipReason
|
||||
this.closeOverlay(false)
|
||||
this.showToast(`已标记跳拣:${reason}`, 'warning')
|
||||
},
|
||||
openPartial(id) {
|
||||
this.partialTargetId = id
|
||||
this.partialInput = ''
|
||||
this.openOverlay('partial')
|
||||
this.$nextTick(() => {
|
||||
this.partialFocused = true
|
||||
})
|
||||
},
|
||||
setPartialInput(value) {
|
||||
this.partialInput = String(value)
|
||||
this.partialFocused = false
|
||||
this.$nextTick(() => {
|
||||
this.partialFocused = true
|
||||
})
|
||||
},
|
||||
confirmPartial() {
|
||||
const item = this.partialTargetItem
|
||||
if (!item) return
|
||||
if (!this.partialInputValid) return
|
||||
const value = parseInt(this.partialInput, 10)
|
||||
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PARTIAL }
|
||||
this.partialQuantities = { ...this.partialQuantities, [item.id]: value }
|
||||
this.closeOverlay(false)
|
||||
this.showToast(`已部分拣货:${value}/${item.qty} ${item.unit}`, 'success')
|
||||
},
|
||||
openBoxList(type) {
|
||||
this.boxListType = type
|
||||
this.boxDropdownOpen = false
|
||||
this.openOverlay('boxList')
|
||||
},
|
||||
boxListDetail(entry) {
|
||||
if (this.boxListType !== 'done') {
|
||||
const draft = this.boxDrafts[entry.index]
|
||||
const processed = draft
|
||||
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
|
||||
: 0
|
||||
return `${entry.box.orderNo} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
|
||||
}
|
||||
const picked = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PICKED).length
|
||||
const partial = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PARTIAL).length
|
||||
return `${entry.box.orderNo} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
|
||||
},
|
||||
selectBoxFromList(entry) {
|
||||
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
|
||||
this.closeOverlay(false)
|
||||
this.requestBoxSwitch(entry.index)
|
||||
},
|
||||
submitPicking() {
|
||||
if (!this.summary.canSubmit || this.completedBoxes[this.currentBoxIndex]) return
|
||||
if (this.summary.pending > 0) {
|
||||
this.selectedIncompleteReason = ''
|
||||
this.openOverlay('submitConfirm')
|
||||
return
|
||||
}
|
||||
this.performSubmit()
|
||||
},
|
||||
confirmIncompleteSubmit() {
|
||||
if (!this.selectedIncompleteReason) return
|
||||
this.performSubmit(this.selectedIncompleteReason)
|
||||
},
|
||||
performSubmit(incompleteReason = '') {
|
||||
const beforeSubmit = this.summary
|
||||
const result = submitStates(this.currentBox.items, this.itemStates)
|
||||
this.itemStates = result.states
|
||||
this.currentBox.items.forEach((item) => {
|
||||
if (result.autoSkippedIds.includes(item.id)) {
|
||||
item.skipReason = `${incompleteReason}(提交时确认未拣)`
|
||||
}
|
||||
item.finalState = this.itemState(item)
|
||||
item.finalPartialQuantity = this.partialQuantities[item.id] || 0
|
||||
})
|
||||
this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
|
||||
this.saveCurrentBoxDraft()
|
||||
const afterSubmit = getPickSummary(this.currentBox.items, this.itemStates)
|
||||
this.completionSummary = {
|
||||
picked: afterSubmit.picked,
|
||||
partial: afterSubmit.partial,
|
||||
skipped: afterSubmit.skipped,
|
||||
autoSkipped: beforeSubmit.pending
|
||||
}
|
||||
this.openOverlay('complete')
|
||||
},
|
||||
nextBox() {
|
||||
this.closeOverlay(false)
|
||||
const nextIndex = this.boxes.findIndex((box, index) => !this.completedBoxes[index])
|
||||
if (nextIndex < 0) {
|
||||
this.openOverlay('allComplete')
|
||||
return
|
||||
}
|
||||
this.selectBox(nextIndex)
|
||||
this.showToast('切换至下一箱', 'success')
|
||||
},
|
||||
resetOverlayState() {
|
||||
this.activeOverlay = ''
|
||||
this.skipTargetId = null
|
||||
this.selectedSkipReason = ''
|
||||
this.partialTargetId = null
|
||||
this.partialInput = ''
|
||||
this.switchTargetIndex = null
|
||||
this.selectedIncompleteReason = ''
|
||||
this.partialFocused = false
|
||||
},
|
||||
closeOverlay() {
|
||||
const popup = this.$refs[`${this.activeOverlay}Popup`]
|
||||
if (popup) popup.close()
|
||||
this.resetOverlayState()
|
||||
},
|
||||
showToast(message, type = '') {
|
||||
clearTimeout(this._toastTimer)
|
||||
this.toast = { visible: true, message, type }
|
||||
this._toastTimer = setTimeout(() => {
|
||||
this.toast.visible = false
|
||||
}, 2200)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">0.0.1</text></view></view><button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
||||
</template>
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'; import PopupHeader from '@/components/PopupHeader.vue'; import { session, requireSession, signOut } from '@/stores/session.js'
|
||||
export default { components: { PopupHeader, PortalHeader }, data() { return { user: session.currentUser, logoutOpen: false, toast: false } }, onShow() { if (!requireSession()) return; this.user = session.currentUser }, beforeUnmount() { clearTimeout(this._toastTimer) }, onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true }, methods: { openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) } } }
|
||||
</script>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page scanner-test-page">
|
||||
<PortalHeader title="广播扫码测试" :user="user" />
|
||||
<view class="scanner-test-content">
|
||||
<view class="scanner-status" :class="`scanner-status-${status}`" role="status">
|
||||
<view class="scanner-status-dot"></view>
|
||||
<view>
|
||||
<text class="scanner-status-title">{{ statusTitle }}</text>
|
||||
<text class="scanner-status-detail">{{ statusDetail }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scanner-result" :class="{ 'scanner-result-received': lastValue }">
|
||||
<text class="scanner-result-label">最近收到</text>
|
||||
<text class="scanner-result-value">{{ lastValue || '等待扫描' }}</text>
|
||||
<text class="scanner-result-meta">{{ lastValue ? `${lastTime} · 本页第 ${scanCount} 次` : '按下 PDA 硬件扫描键' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="scanner-config">
|
||||
<view class="scanner-config-row"><text class="scanner-config-label">Action</text><text class="scanner-config-value">{{ action }}</text></view>
|
||||
<view class="scanner-config-row"><text class="scanner-config-label">Extra 标签</text><text class="scanner-config-value">{{ extra }}</text></view>
|
||||
</view>
|
||||
|
||||
<view class="scanner-history-head">
|
||||
<text class="scanner-history-title">接收记录</text>
|
||||
<button class="scanner-clear" :disabled="!history.length" @tap="clearHistory">清空</button>
|
||||
</view>
|
||||
<view v-if="history.length" class="scanner-history">
|
||||
<view v-for="item in history" :key="item.id" class="scanner-history-item">
|
||||
<text class="scanner-history-index">{{ item.index }}</text>
|
||||
<view class="scanner-history-main"><text class="scanner-history-value">{{ item.value }}</text><text class="scanner-history-time">{{ item.time }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="scanner-empty"><text>暂无广播数据</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { SCAN_BROADCAST_ACTION, SCAN_BROADCAST_EXTRA, startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader },
|
||||
data() {
|
||||
return {
|
||||
user: session.currentUser,
|
||||
action: SCAN_BROADCAST_ACTION,
|
||||
extra: SCAN_BROADCAST_EXTRA,
|
||||
status: 'waiting',
|
||||
lastValue: '',
|
||||
lastTime: '',
|
||||
scanCount: 0,
|
||||
history: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
statusTitle() {
|
||||
if (this.status === 'error') return '接收器启动失败'
|
||||
if (this.status === 'unsupported') return '当前环境不支持广播'
|
||||
return this.lastValue ? '已收到扫描广播' : '接收器已启动'
|
||||
},
|
||||
statusDetail() {
|
||||
if (this.status === 'error') return '请检查 PDA 扫描服务设置后重新进入页面'
|
||||
if (this.status === 'unsupported') return '请在 Android App 真机中测试'
|
||||
return this.lastValue ? '继续扫描可验证连续接收' : '正在等待硬件扫描结果'
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
if (!requireSession()) return
|
||||
this.user = session.currentUser
|
||||
this.startScanner()
|
||||
},
|
||||
onHide() { this.stopScanner() },
|
||||
onUnload() { this.stopScanner() },
|
||||
beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
startScanner() {
|
||||
this.stopScanner()
|
||||
this.status = 'waiting'
|
||||
// #ifndef APP-PLUS
|
||||
this.status = 'unsupported'
|
||||
// #endif
|
||||
this._stopScanner = startScanBroadcast({
|
||||
onScan: (value) => this.recordScan(value),
|
||||
onError: () => { this.status = 'error' }
|
||||
})
|
||||
},
|
||||
stopScanner() {
|
||||
if (typeof this._stopScanner !== 'function') return
|
||||
this._stopScanner()
|
||||
this._stopScanner = null
|
||||
},
|
||||
recordScan(value) {
|
||||
const now = new Date()
|
||||
const time = [now.getHours(), now.getMinutes(), now.getSeconds()].map((part) => String(part).padStart(2, '0')).join(':')
|
||||
this.scanCount += 1
|
||||
this.lastValue = value
|
||||
this.lastTime = time
|
||||
this.status = 'received'
|
||||
this.history = [{ id: `${Date.now()}-${this.scanCount}`, index: this.scanCount, value, time }, ...this.history].slice(0, 20)
|
||||
},
|
||||
clearHistory() {
|
||||
this.lastValue = ''
|
||||
this.lastTime = ''
|
||||
this.scanCount = 0
|
||||
this.history = []
|
||||
this.status = 'waiting'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scanner-test-content { padding: 16px 14px calc(24px + env(safe-area-inset-bottom)); }
|
||||
.scanner-status { display: flex; align-items: center; min-height: 64px; padding: 12px 14px; border-radius: 8px; color: #20405f; background: #e7f1ff; }
|
||||
.scanner-status-dot { width: 12px; height: 12px; margin-right: 12px; border-radius: 50%; background: #1769e8; }
|
||||
.scanner-status-title, .scanner-status-detail, .scanner-result-label, .scanner-result-value, .scanner-result-meta, .scanner-config-label, .scanner-config-value, .scanner-history-title, .scanner-history-value, .scanner-history-time { display: block; }
|
||||
.scanner-status-title { font-size: 15px; font-weight: 700; }
|
||||
.scanner-status-detail { margin-top: 3px; font-size: 12px; line-height: 18px; }
|
||||
.scanner-status-received { color: #145c38; background: #ddf5e8; }
|
||||
.scanner-status-received .scanner-status-dot { background: #168552; }
|
||||
.scanner-status-error { color: #912018; background: #fee7e5; }
|
||||
.scanner-status-error .scanner-status-dot { background: #d92d20; }
|
||||
.scanner-status-unsupported { color: #664d10; background: #fff1c7; }
|
||||
.scanner-status-unsupported .scanner-status-dot { background: #b7791f; }
|
||||
.scanner-result { min-height: 168px; margin-top: 14px; padding: 18px 16px; border: 2px dashed #aebdce; border-radius: 8px; background: #ffffff; }
|
||||
.scanner-result-received { border-style: solid; border-color: #168552; }
|
||||
.scanner-result-label { color: #63758a; font-size: 12px; font-weight: 700; }
|
||||
.scanner-result-value { margin-top: 26px; color: #27384d; font-size: 24px; font-weight: 800; line-height: 32px; overflow-wrap: anywhere; text-align: center; }
|
||||
.scanner-result-received .scanner-result-value { color: #11643c; }
|
||||
.scanner-result-meta { margin-top: 15px; color: #63758a; font-size: 12px; text-align: center; }
|
||||
.scanner-config { margin-top: 14px; border-radius: 8px; background: #ffffff; }
|
||||
.scanner-config-row { padding: 13px 14px; border-bottom: 1px solid #e4e9ef; }
|
||||
.scanner-config-row:last-child { border-bottom: 0; }
|
||||
.scanner-config-label { margin-bottom: 5px; color: #63758a; font-size: 11px; font-weight: 700; }
|
||||
.scanner-config-value { color: #27384d; font-size: 13px; line-height: 19px; overflow-wrap: anywhere; }
|
||||
.scanner-history-head { display: flex; align-items: center; justify-content: space-between; margin-top: 22px; }
|
||||
.scanner-history-title { font-size: 16px; font-weight: 800; }
|
||||
.scanner-clear { width: 64px; height: 48px; margin: 0; padding: 0; color: #1769e8; font-size: 13px; line-height: 48px; background: transparent; }
|
||||
.scanner-clear[disabled] { color: #9ba8b7; }
|
||||
.scanner-history { margin-top: 8px; border-radius: 8px; background: #ffffff; }
|
||||
.scanner-history-item { display: flex; align-items: center; min-height: 62px; padding: 9px 13px; border-bottom: 1px solid #e4e9ef; }
|
||||
.scanner-history-item:last-child { border-bottom: 0; }
|
||||
.scanner-history-index { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; margin-right: 11px; border-radius: 50%; color: #ffffff; font-size: 12px; font-weight: 700; background: #1769e8; }
|
||||
.scanner-history-main { min-width: 0; flex: 1; }
|
||||
.scanner-history-value { color: #27384d; font-size: 15px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.scanner-history-time { margin-top: 3px; color: #718095; font-size: 11px; }
|
||||
.scanner-empty { display: flex; align-items: center; justify-content: center; height: 96px; margin-top: 8px; border-radius: 8px; color: #718095; font-size: 13px; background: #ffffff; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user