This commit is contained in:
2026-08-26 16:58:41 +08:00
parent 083299602d
commit 76077369c2
14 changed files with 136 additions and 195 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
<template><view v-if="user" class="page-shell portal-page"><PortalHeader :title="$t('history.title')" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">0</text><text class="summary-label">{{ $t('history.completedToday') }}</text></view><view><text class="summary-number pending-color">0</text><text class="summary-label">{{ $t('history.inProgress') }}</text></view></view><text class="group-title">{{ $t('history.recent') }}</text><view class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">{{ $t('history.emptyTitle') }}</text><text class="history-empty-desc">{{ $t('history.emptyDescription') }}</text></view></view></view></template>
<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">0</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">0</text><text class="summary-label">进行中</text></view></view><text class="group-title">最近作业</text><view class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无海外仓作业记录</text><text class="history-empty-desc">接入海外仓真实任务后,完成记录会显示在这里</text></view></view></view></template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'
export default { components: { PortalHeader }, data() { return { user: session.currentUser } }, onShow() { if (!requireSession()) return; this.user = session.currentUser } }
+14 -14
View File
@@ -1,23 +1,23 @@
<template>
<view v-if="user" class="page-shell portal-page home-page">
<PortalHeader ref="portalHeader" :title="$t('app.workbench')" :user="user" menu @menu-change="menuOpen = $event" />
<PortalHeader ref="portalHeader" title="海外仓工作台" :user="user" menu @menu-change="menuOpen = $event" />
<view class="home-menu">
<view class="home-overview">
<view class="home-overview-copy">
<text class="home-overview-eyebrow">{{ $t('home.eyebrow') }}</text>
<text class="home-overview-eyebrow">OVERSEAS WAREHOUSE</text>
<text class="home-overview-title">{{ greeting }},{{ displayName }}</text>
<text class="home-overview-warehouse">{{ warehouseLabel }}</text>
</view>
<view class="home-overview-status"><text class="home-overview-status-value">{{ availableCount }}</text><text class="home-overview-status-label">{{ $t('home.actionable') }}</text></view>
<view class="home-overview-status"><text class="home-overview-status-value">{{ availableCount }}</text><text class="home-overview-status-label">项可处理</text></view>
</view>
<view v-for="group in groups" :key="group.key" class="home-group">
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ $t(group.nameKey) }}</text><text class="home-group-count">{{ group.operations.length }}</text></view>
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text><text class="home-group-count">{{ group.operations.length }}</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">{{ $t(operation.nameKey) }}</text>
<text v-if="operation.available" class="home-operation-pending">{{ $t('common.available') }}</text>
<text v-else class="home-operation-unavailable">{{ $t('common.unavailable') }}</text>
<text class="home-operation-name">{{ operation.name }}</text>
<text v-if="operation.available" class="home-operation-pending">可处理</text>
<text v-else class="home-operation-unavailable">暂未开放</text>
</button>
</view>
</view>
@@ -44,20 +44,20 @@
availableCount() { return this.groups.reduce((total, group) => total + group.operations.filter((operation) => operation.available).length, 0) },
displayName() {
const name = String(this.user?.name || '').trim()
return name && !/^[a-z0-9._-]+$/i.test(name) ? name : this.$t('common.warehouseOperator')
return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员'
},
warehouseLabel() {
if (this.user?.warehouseName) return this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName
const warehouseId = String(this.user?.warehouseId || '')
if (warehouseId) return `${this.$t('common.currentWarehouse')} · ${warehouseId.slice(-6)}`
return this.user?.warehouse || this.$t('common.warehouseFallback')
if (warehouseId) return `当前作业仓 · ${warehouseId.slice(-6)}`
return this.user?.warehouse || '仓库由当前账号自动绑定'
},
greeting() {
const hour = new Date().getHours()
if (hour < 6) return this.$t('home.night')
if (hour < 12) return this.$t('home.morning')
if (hour < 18) return this.$t('home.afternoon')
return this.$t('home.evening')
if (hour < 6) return '夜间作业请注意安全'
if (hour < 12) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
}
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() },
+8 -8
View File
@@ -2,13 +2,13 @@
<view class="page-shell login-page">
<view class="login-brand">
<image class="login-logo-icon" src="/static/brand-mark-white.png" mode="aspectFit" aria-hidden="true" />
<text class="login-logo-title">{{ $t('app.title') }}</text><text class="login-logo-sub">{{ $t('app.subtitle') }}</text>
<text class="login-logo-title">柏悦物流海外版</text><text class="login-logo-sub">海外仓现场作业系统</text>
</view>
<view class="login-card">
<view class="login-field"><text class="login-field-label">{{ $t('login.username') }}</text><input v-model="form.userId" class="login-field-input" :aria-label="$t('login.username')" :placeholder="$t('login.usernamePlaceholder')" confirm-type="next" @focus="error = ''" /></view>
<view class="login-field"><text class="login-field-label">{{ $t('login.password') }}</text><input v-model="form.password" class="login-field-input" :aria-label="$t('login.password')" type="password" :placeholder="$t('login.passwordPlaceholder')" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
<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>
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? $t('login.submitting') : $t('login.submit') }}</button>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
</view>
</view>
</template>
@@ -24,19 +24,19 @@
methods: {
async login() {
const userId = this.form.userId.trim()
if (!userId) return void (this.error = this.$t('login.usernameRequired'))
if (!this.form.password) return void (this.error = this.$t('login.passwordRequired'))
if (!userId) return void (this.error = '请输入工号')
if (!this.form.password) return void (this.error = '请输入密码')
this.submitting = true
this.error = ''
try {
requireOnline(this.$t('login.offline'))
requireOnline('当前网络不可用,无法登录')
const tokens = await loginRequest(userId, this.form.password)
signInWithTokens(tokens)
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
console.error('[Overseas PDA login] failed', { message: error.message, network: error.network === true,
code: error.code, requestId: error.requestId, traceId: error.traceId })
this.error = error.network ? this.$t('login.offline') : (error.message || this.$t('login.failed'))
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
} finally {
this.submitting = false
}
+5 -5
View File
@@ -1,11 +1,11 @@
<template>
<view v-if="user && currentModule" class="page-shell portal-page">
<PortalHeader :title="$t('module.title')" :user="user" />
<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 class="module-hero-copy"><text class="module-hero-title">{{ $t(currentModule.nameKey) }}</text><text class="module-hero-desc">{{ $t(currentModule.descriptionKey) }}</text></view><text class="module-hero-count">{{ $t('module.availability', { available: availableCount, total: currentModule.operations.length }) }}</text></view>
<view class="section-heading"><text class="group-title">{{ $t('module.functions') }}</text><text class="section-heading-hint">{{ $t('module.hint') }}</text></view>
<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">{{ $t(operation.nameKey) }}</text><text class="operation-desc">{{ $t(operation.descriptionKey) }}</text></view><text v-if="operation.available" class="operation-ready">{{ $t('common.available') }}</text><text v-else class="operation-locked">{{ $t('common.unavailable') }}</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">{{ $t('module.onlyReceiving') }}</view>
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view class="module-hero-copy"><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view><text class="module-hero-count">{{ availableCount }}/{{ currentModule.operations.length }} 可用</text></view>
<view class="section-heading"><text class="group-title">业务功能</text><text class="section-heading-hint">点击可用功能开始作业</text></view>
<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-ready">可处理</text><text v-else class="operation-locked">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">当前仅开放拆柜收货,其他作业暂未开放。</view>
</view>
</view>
</template>
+56 -57
View File
@@ -1,86 +1,86 @@
<template>
<view v-if="user" class="page-shell portal-page receiving-page">
<PortalHeader :title="inboundMode ? $t('operation.title') : operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
<PortalHeader :title="inboundMode ? '拆柜收货' : operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
<view class="portal-content receiving-content">
<template v-if="inboundMode">
<view class="receiving-heading"><view><text class="receiving-title">{{ pageTitle }}</text><text class="receiving-subtitle">{{ pageSubtitle }}</text></view></view>
<view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>{{ $t('operation.restoring') }}</text></view>
<view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>正在恢复当前柜作业…</text></view>
<template v-else-if="phase === 'candidates'">
<view class="scan-card">
<text class="section-label">{{ $t('operation.scanContainer') }}</text>
<ScanTargetInput name="container" v-model="containerInput" :label="$t('operation.container')" :placeholder="$t('operation.containerPlaceholder')" :active="activeTarget === 'container'" @activate="activeTarget = 'container'" @submit="openContainer" />
<button class="primary-button" :disabled="busy || !containerInput.trim()" @tap="openContainer(containerInput)">{{ $t('operation.queryContainer') }}</button>
<text class="section-label">扫描柜号</text>
<ScanTargetInput name="container" v-model="containerInput" label="柜号" placeholder="扫描或人工输入柜号" :active="activeTarget === 'container'" @activate="activeTarget = 'container'" @submit="openContainer" />
<button class="primary-button" :disabled="busy || !containerInput.trim()" @tap="openContainer(containerInput)">查询柜号</button>
</view>
<view class="list-toolbar"><text>{{ $t('operation.pendingContainers', { count: candidates.length }) }}</text><button :disabled="busy" @tap="loadCandidates">{{ $t('common.refresh') }}</button></view>
<view v-if="!candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>{{ $t('operation.emptyContainers') }}</text></view>
<view class="list-toolbar"><text>{{ candidates.length }} 个待卸货集装箱</text><button :disabled="busy" @tap="loadCandidates">刷新</button></view>
<view v-if="!candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text></view>
<view v-for="item in candidates" :key="item.containerNo" class="container-card">
<view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="container-meta">{{ item.inboundCodeCount }} {{ $t('common.inboundCodes') }} · {{ item.planCount }} {{ $t('common.plans') }}<template v-if="item.plannedPalletCount != null"> · {{ item.plannedPalletCount }} {{ $t('common.pallets') }}</template></text><text v-if="item.destinationCodes?.length" class="container-meta">{{ $t('operation.destination', { value: item.destinationCodes.join(' / ') }) }}</text></view><text class="status-tag" :class="statusTone(item.status)">{{ statusLabel(item.status) }}</text></view>
<view class="progress-row"><text>{{ $t('operation.receivingProgress') }}</text><text><strong>{{ item.receivedPackages }}</strong> / {{ item.totalPackages }} {{ $t('common.boxes') }}</text></view>
<view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="container-meta">{{ item.inboundCodeCount }} 个入仓码 · {{ item.planCount }} 个计划<template v-if="item.plannedPalletCount != null"> · {{ item.plannedPalletCount }} 托</template></text><text v-if="item.destinationCodes?.length" class="container-meta">目的仓:{{ item.destinationCodes.join(' / ') }}</text></view><text class="status-tag" :class="statusTone(item.status)">{{ statusLabel(item.status) }}</text></view>
<view class="progress-row"><text>收货进度</text><text><strong>{{ item.receivedPackages }}</strong> / {{ item.totalPackages }} 箱</text></view>
<view class="progress-track"><view class="progress-fill" :style="{ width: percent(item.receivedPackages, item.totalPackages) + '%' }"></view></view>
<button class="primary-button" :disabled="busy" @tap="openContainer(item.containerNo)">{{ item.currentAccount ? $t('operation.continueCurrent') : $t('operation.viewDetail') }}</button>
<button class="primary-button" :disabled="busy" @tap="openContainer(item.containerNo)">{{ item.currentAccount ? '继续当前柜' : '查看详情' }}</button>
</view>
</template>
<template v-else-if="phase === 'detail'">
<ReceivingSessionSummary :session="selected" />
<view class="lock-notice">{{ $t('operation.lockedNotice') }}</view>
<view class="sticky-action"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">{{ $t('operation.backList') }}</button><button class="primary-button" :disabled="busy" @tap="prepareContainer">{{ $t('operation.selectContainer') }}</button></view>
<view class="lock-notice">选择后账号将锁定当前柜,完成前不能切换其他柜号。</view>
<view class="sticky-action"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button><button class="primary-button" :disabled="busy" @tap="prepareContainer">选择此柜并开始拍照</button></view>
</template>
<template v-else-if="phase === 'sign'">
<ReceivingSessionSummary :session="current" />
<view class="photo-card">
<view class="card-head"><view><text class="card-title">{{ $t('operation.unloadPhotos') }}</text><text class="container-meta">{{ $t('operation.photoHint') }}</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view>
<view class="card-head"><view><text class="card-title">卸货签收照片</text><text class="container-meta">三个固定槽位各拍摄 1 张现场照片</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view>
<view class="photo-grid">
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
<view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)">
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" />
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>{{ $t('operation.notAdded') }}</text></view>
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>未添加</text></view>
</view>
<view class="photo-role-info">
<view class="photo-role-head"><text>{{ step.label }}</text><text :class="photoSlots[index]?.fileId ? 'photo-valid' : 'photo-required'">{{ photoSlots[index]?.fileId ? $t('common.uploaded') : $t('common.required') }}</text></view>
<view class="photo-role-head"><text>{{ step.label }}</text><text :class="photoSlots[index]?.fileId ? 'photo-valid' : 'photo-required'">{{ photoSlots[index]?.fileId ? '已上传' : '必传' }}</text></view>
<text class="photo-state">{{ photoStateLabel(photoSlots[index]) }}</text>
<text v-if="photoSlots[index]?.status === 'error'" class="photo-error">{{ photoSlots[index].errorMessage || $t('common.retry') }}</text>
<text v-if="photoSlots[index]?.status === 'error'" class="photo-error">{{ photoSlots[index].errorMessage || '请重试' }}</text>
</view>
<view class="photo-actions"><button :disabled="busy" @tap="choosePhoto(index)">{{ photoSlots[index]?.fileId ? $t('operation.retake') : $t('operation.takePhoto') }}</button></view>
<view class="photo-actions"><button :disabled="busy" @tap="choosePhoto(index)">{{ photoSlots[index]?.fileId ? '重拍' : '拍照' }}</button></view>
</view>
</view>
</view>
<view class="sticky-action one"><button class="primary-button" :disabled="busy || !photosReady" @tap="signContainer">{{ $t('operation.signAndReceive') }}</button></view>
<view class="sticky-action one"><button class="primary-button" :disabled="busy || !photosReady" @tap="signContainer">确认签收并开始收货</button></view>
</template>
<template v-else-if="phase === 'receive'">
<ReceivingSessionSummary :session="current" />
<view class="scan-card">
<text class="section-label">{{ $t('operation.scanCartonStep') }}</text>
<ScanTargetInput name="carton" v-model="cartonInput" :label="$t('operation.carton')" placeholder="NYEXX2608260001#4-1" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" />
<button class="secondary-button" :disabled="busy || !cartonInput.trim()" @tap="resolveCarton(cartonInput)">{{ $t('operation.parseCarton') }}</button>
<text class="section-label">1. 扫描箱码定位入仓码</text>
<ScanTargetInput name="carton" v-model="cartonInput" label="箱码" placeholder="NYEXX2608260001#4-1" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" />
<button class="secondary-button" :disabled="busy || !cartonInput.trim()" @tap="resolveCarton(cartonInput)">解析箱码</button>
<template v-if="activeInbound">
<view class="matched-inbound-card">
<view class="mode-banner" :class="activeInbound.destinationType === 'FBA' ? 'fba' : 'overseas'"><text>{{ activeInbound.destinationType === 'FBA' ? $t('operation.fbaMode') : $t('operation.overseasMode') }}</text><text>{{ destinationLabel(activeInbound) }}</text></view>
<view class="mode-banner" :class="activeInbound.destinationType === 'FBA' ? 'fba' : 'overseas'"><text>{{ activeInbound.destinationType === 'FBA' ? 'FBA 按箱数收货' : '海外仓逐箱收货' }}</text><text>{{ destinationLabel(activeInbound) }}</text></view>
<text class="matched-inbound-code">{{ activeInbound.inboundCode }}</text>
<view class="matched-inbound-progress"><text>{{ $t('operation.totalBoxes', { count: activeInbound.totalPackages }) }}</text><text>{{ $t('operation.receivedBoxes', { count: activeInbound.receivedPackages }) }}</text><text>{{ $t('operation.pendingBoxes', { count: activeInbound.pendingPackages }) }}</text></view>
<view class="matched-inbound-progress"><text>总计 {{ activeInbound.totalPackages }} 箱</text><text>已收 {{ activeInbound.receivedPackages }} 箱</text><text>待收 {{ activeInbound.pendingPackages }} 箱</text></view>
</view>
<text class="section-label">{{ $t('operation.scanPalletStep') }}</text>
<ScanTargetInput name="pallet" v-model="palletInput" :label="$t('operation.pallet')" :placeholder="$t('operation.palletPlaceholder')" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" />
<button class="secondary-button" :disabled="busy || !palletInput.trim()" @tap="resolvePallet(palletInput)">{{ $t('operation.verifyPallet') }}</button>
<view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? $t('operation.palletMatched') : $t('operation.palletMismatch') }}</text></view>
<text class="section-label">2. 扫描实物托盘码</text>
<ScanTargetInput name="pallet" v-model="palletInput" label="托盘码" placeholder="扫描或人工输入托盘码" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" />
<button class="secondary-button" :disabled="busy || !palletInput.trim()" @tap="resolvePallet(palletInput)">校验托盘</button>
<view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? '目的仓匹配,可收货' : '托盘不匹配' }}</text></view>
<template v-if="activeInbound.destinationType === 'FBA'">
<text class="section-label">{{ $t('operation.inputCountStep') }}</text>
<input v-model.number="packageCount" class="quantity-input" type="number" inputmode="numeric" :placeholder="$t('operation.maxCount', { count: activeInbound.pendingPackages })" />
<text class="section-label">3. 输入本次箱数</text>
<input v-model.number="packageCount" class="quantity-input" type="number" inputmode="numeric" :placeholder="`最多可收 ${activeInbound.pendingPackages} 箱`" />
</template>
<button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? $t('operation.confirmBulk') : $t('operation.confirmCarton') }}</button>
<button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? '确认按箱数收货' : '确认本箱收货' }}</button>
</template>
</view>
<view v-if="lastReceipt" class="success-card">{{ $t('operation.receiptSuccess', { accepted: lastReceipt.acceptedPackages, inbound: lastReceipt.inboundCode, pending: lastReceipt.pendingPackages }) }}</view>
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || !allInboundsReconciled" @tap="completeContainer">{{ allInboundsReconciled ? $t('operation.completeReady') : $t('operation.incomplete', { count: progress.pending }) }}</button></view>
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱</view>
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || !allInboundsReconciled" @tap="completeContainer">{{ allInboundsReconciled ? '每个入仓码已核平,完成整柜入库' : `尚有 ${progress.pending} 箱未收` }}</button></view>
</template>
<template v-else-if="phase === 'completed'">
<view class="completed-card"><view class="completed-icon">✓</view><text class="completed-title">{{ $t('operation.completedTitle') }}</text><text>{{ current?.containerNo }}</text><text>{{ $t('operation.actualReceipt', { received: current?.receivedPackages, total: current?.totalPackages }) }}</text><button class="primary-button" @tap="finishAndReturn">{{ $t('operation.returnPending') }}</button></view>
<view class="completed-card"><view class="completed-icon">✓</view><text class="completed-title">当前柜已完成入库</text><text>{{ current?.containerNo }}</text><text>实收 {{ current?.receivedPackages }} / {{ current?.totalPackages }} 箱</text><button class="primary-button" @tap="finishAndReturn">返回待卸货列表</button></view>
</template>
<view v-if="error" class="error-card">
@@ -90,8 +90,8 @@
</template>
<template v-else>
<view class="receiving-heading"><view><text class="receiving-title">{{ operationName }}</text><text class="receiving-subtitle">{{ $t('operation.unavailableSubtitle') }}</text></view></view>
<view class="blocked-card"><uni-icons type="locked" color="#8090a3" size="36" /><text class="blocked-title">{{ $t('operation.unavailableTitle') }}</text><text>{{ $t('operation.unavailableDescription') }}</text></view>
<view class="receiving-heading"><view><text class="receiving-title">{{ operationName }}</text><text class="receiving-subtitle">当前功能暂未开放</text></view></view>
<view class="blocked-card"><uni-icons type="locked" color="#8090a3" size="36" /><text class="blocked-title">功能暂未开放</text><text>现有平台、客户或内部接口不能由仓库作业员直接调用。扫码不会提交,也不会产生模拟成功结果。</text></view>
</template>
</view>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
@@ -111,11 +111,10 @@
export default {
components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput },
data() { return { user: session.currentUser, moduleKey: 'query', opKey: 'inventoryQuery', loading: false, busy: false, bootstrapped: false, detailOpen: false, containerInput: '', cartonInput: '', palletInput: '', packageCount: 1, error: null, toast: { visible: false, message: '', type: '' } } },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, bootstrapped: false, detailOpen: false, containerInput: '', cartonInput: '', palletInput: '', packageCount: 1, error: null, toast: { visible: false, message: '', type: '' } } },
computed: {
inboundMode() { return this.moduleKey === 'inbound' },
operationName() { return this.$t(`modules.${this.opKey}.name`) },
photoSteps() { return RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: this.$t(({ LICENSE_PLATE: 'operation.photoLicense', CONTAINER_SEAL: 'operation.photoSeal', DOOR_OPEN: 'operation.photoDoor' })[role]) })) },
photoSteps() { return RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: ({ LICENSE_PLATE: '车牌照片', CONTAINER_SEAL: '集装箱封签', DOOR_OPEN: '开柜现场' })[role] })) },
candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots },
resolvedCarton() { return receiving.resolvedCarton }, resolvedPallet() { return receiving.resolvedPallet }, lastReceipt() { return receiving.lastReceipt },
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
@@ -123,38 +122,38 @@
uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 && !this.photoSlots.some((item) => item?.status === 'uploading') },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return Boolean(this.activeInbound.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
pageTitle() { return this.$t(`operationPhase.${this.phase}.title`) },
pageSubtitle() { return this.$t(`operationPhase.${this.phase}.subtitle`) }
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
onLoad(options) { this.moduleKey = options.module || 'query'; this.opKey = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery') },
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标收货', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && !this.bootstrapped) this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
methods: {
statusLabel(status) { const key = ({ WAIT_SIGN: 'statusWait', SIGNED: 'statusSigned', RECEIVING: 'statusReceiving', COMPLETED: 'statusCompleted' })[status]; return key ? this.$t(`operation.${key}`) : status },
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成' })[status] || status },
statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' }, percent(received, total) { return total ? Math.min(100, Math.round(Number(received || 0) / Number(total) * 100)) : 0 },
destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || this.$t('modules.inbound.name')) },
photoStateLabel(photo) { if (photo?.status === 'uploading') return this.$t('operation.uploading'); if (photo?.status === 'error') return this.$t(photo.fileId ? 'operation.retakeFailedKept' : 'operation.uploadFailed'); return this.$t(photo?.fileId ? 'operation.previewPhoto' : 'operation.takePhotoHint') },
destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || '海外仓') },
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'; return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (!this.inboundMode) return this.showToast(this.$t('operation.scanUnavailable'), 'warning'); if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'pallet' && this.activeInbound) { this.palletInput = value; return this.resolvePallet(value) } this.cartonInput = value; return this.resolveCarton(value) },
async run(actionKey, work) { const action = this.$t(`operation.actions.${actionKey}`); this.busy = true; this.error = null; try { const result = await work(); this.showToast(this.$t('operation.actionSuccess', { action }), 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || this.$t('operation.actionFailed', { action }), 'error'); throw error } finally { this.busy = false } },
handleHardwareScan(value) { if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'pallet' && this.activeInbound) { this.palletInput = value; return this.resolvePallet(value) } this.cartonInput = value; return this.resolveCarton(value) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || `${action}失败`, 'error'); throw error } finally { this.busy = false } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' },
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || this.$t('operation.photoLoadFailed'); this.error = error } })) },
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) },
async applyCurrentReceiving(value) { setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) },
async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.bootstrapped = true } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false); this.bootstrapped = true } else this.error = error } finally { this.loading = false } },
async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = null; this.showToast(this.$t('operation.currentConflict', { container: current.containerNo }), 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('refresh', work) : await work() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('query', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('刷新待卸柜', work) : await work() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('select', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (path) this.replacePhoto(index, path) } }) },
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(this.$t('operation.actionSuccess', { action: this.photoSteps[index].label }), 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || this.$t('operation.retakeFailedKept') } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || this.$t('operation.uploadFailed') }); this.error = error; playErrorSound() } finally { this.busy = false } },
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; playErrorSound() } finally { this.busy = false } },
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('sign', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'carton' },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || this.busy) return; try { const value = await this.run('carton', () => resolveCurrentCarton(cartonMark)); receiving.resolvedCarton = value; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('pallet', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; if (!value.compatible) throw Object.assign(new Error(this.$t('operation.palletMismatch')), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('receipt', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('complete', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || this.busy) return; try { const value = await this.run('解析箱码', () => resolveCurrentCarton(cartonMark)); receiving.resolvedCarton = value; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('提交实收', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
}
+12 -17
View File
@@ -1,43 +1,38 @@
<template>
<view v-if="user" class="page-shell portal-page">
<PortalHeader :title="$t('profile.title')" :user="user" :show-profile="false" />
<PortalHeader title="个人中心" :user="user" :show-profile="false" />
<view class="portal-content profile-content">
<view class="profile-card"><view class="profile-avatar">{{ avatar }}</view><view><text class="profile-name">{{ displayName }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view>
<view class="profile-list">
<view class="profile-row"><text>{{ $t('profile.warehouse') }}</text><text class="profile-value">{{ warehouseDisplay }}</text></view>
<view class="profile-row"><text>{{ $t('profile.shift') }}</text><text class="profile-value">{{ $t('profile.shiftValue') }}</text></view>
<view class="profile-row"><text>{{ $t('profile.version') }}</text><text class="profile-value">0.1.0</text></view>
<picker mode="selector" :range="localeLabels" :value="localeIndex" @change="changeLocale"><view class="profile-row language-row"><text>{{ $t('profile.language') }}</text><text class="profile-value">{{ localeLabels[localeIndex] }} ›</text></view></picker>
<view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ warehouseDisplay }}</text></view>
<view class="profile-row"><text>当前班次</text><text class="profile-value">以海外仓排班为准</text></view>
<view class="profile-row"><text>应用版本</text><text class="profile-value">0.1.0</text></view>
</view>
<button class="profile-action" @tap="showSettings">{{ $t('profile.settings') }} <text>›</text></button>
<button class="logout-action" @tap="openLogout">{{ $t('profile.logout') }}</button>
<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="$t('profile.logoutTitle')"><PopupHeader :title="$t('profile.logoutTitle')" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">{{ $t('profile.logoutDescription') }}</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">{{ $t('common.cancel') }}</button><button class="logout-dialog-confirm" @tap="logout">{{ $t('common.confirm') }}</button></view></view></uni-popup>
<view v-if="toast" class="app-toast warning" role="status">{{ $t('profile.settingsLater') }}</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">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</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 { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
export default {
components: { PopupHeader, PortalHeader },
data() { return { user: session.currentUser, logoutOpen: false, toast: false, locale: currentLocale() } },
data() { return { user: session.currentUser, logoutOpen: false, toast: false } },
computed: {
localeLabels() { return LOCALE_OPTIONS.map((item) => this.$t(item.labelKey)) },
localeIndex() { const index = LOCALE_OPTIONS.findIndex((item) => item.value === this.locale); return Math.max(0, index) },
displayName() { const name = String(this.user?.name || ''); return name && !/^[a-z0-9._-]+$/i.test(name) ? name : this.$t('common.warehouseOperator') },
displayName() { const name = String(this.user?.name || ''); return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员' },
avatar() { return this.displayName.slice(0, 1).toUpperCase() },
warehouseDisplay() { return this.user?.warehouseName ? (this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName) : (this.user?.warehouse || this.$t('profile.warehousePending')) }
warehouseDisplay() { return this.user?.warehouseName ? (this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName) : (this.user?.warehouse || '正在读取仓库主档') }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.locale = currentLocale(); this.loadWarehouseProfile() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() },
beforeUnmount() { clearTimeout(this._toastTimer) },
onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true },
methods: {
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
changeLocale(event) { this.locale = setLocale(LOCALE_OPTIONS[Number(event.detail.value)]?.value || 'en') },
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' }) }
+14 -14
View File
@@ -1,6 +1,6 @@
<template>
<view v-if="user" class="page-shell portal-page scanner-test-page">
<PortalHeader :title="$t('scanner.title')" :user="user" />
<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>
@@ -11,19 +11,19 @@
</view>
<view class="scanner-result" :class="{ 'scanner-result-received': lastValue }">
<text class="scanner-result-label">{{ $t('scanner.latest') }}</text>
<text class="scanner-result-value">{{ lastValue || $t('scanner.waiting') }}</text>
<text class="scanner-result-meta">{{ lastValue ? `${lastTime} · ${$t('scanner.count', { count: scanCount })}` : $t('scanner.hardwareHint') }}</text>
<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">{{ $t('scanner.extra') }}</text><text class="scanner-config-value">{{ extra }}</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">{{ $t('scanner.records') }}</text>
<button class="scanner-clear" :disabled="!history.length" @tap="clearHistory">{{ $t('scanner.clear') }}</button>
<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">
@@ -31,7 +31,7 @@
<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>{{ $t('scanner.empty') }}</text></view>
<view v-else class="scanner-empty"><text>暂无广播数据</text></view>
</view>
</view>
</template>
@@ -57,14 +57,14 @@
},
computed: {
statusTitle() {
if (this.status === 'error') return this.$t('scanner.errorTitle')
if (this.status === 'unsupported') return this.$t('scanner.unsupportedTitle')
return this.$t(this.lastValue ? 'scanner.receivedTitle' : 'scanner.waitingTitle')
if (this.status === 'error') return '接收器启动失败'
if (this.status === 'unsupported') return '当前环境不支持广播'
return this.lastValue ? '已收到扫描广播' : '接收器已启动'
},
statusDetail() {
if (this.status === 'error') return this.$t('scanner.errorDetail')
if (this.status === 'unsupported') return this.$t('scanner.unsupportedDetail')
return this.$t(this.lastValue ? 'scanner.receivedDetail' : 'scanner.waitingDetail')
if (this.status === 'error') return '请检查 PDA 扫描服务设置后重新进入页面'
if (this.status === 'unsupported') return '请在 Android App 真机中测试'
return this.lastValue ? '继续扫描可验证连续接收' : '正在等待硬件扫描结果'
}
},
onShow() {