This commit is contained in:
2026-08-26 16:50:25 +08:00
parent 5868e774d4
commit 083299602d
31 changed files with 816 additions and 239 deletions
+1 -3
View File
@@ -1,6 +1,4 @@
<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>
<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>
<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 } }
+40 -8
View File
@@ -1,15 +1,23 @@
<template>
<view v-if="user" class="page-shell portal-page home-page">
<PortalHeader ref="portalHeader" title="首页" :user="user" menu @menu-change="menuOpen = $event" />
<PortalHeader ref="portalHeader" :title="$t('app.workbench')" :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-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>
<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>{{ group.name }}</text></view>
<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-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">可处理</text>
<text v-else class="home-operation-unavailable">暂未开放</text>
<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>
</button>
</view>
</view>
@@ -19,8 +27,9 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { MODULES } from '@/data/overseas-workbench.js'
import { session, requireSession } from '@/stores/session.js'
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
const HOME_GROUPS = MODULES.map((module) => ({
...module,
@@ -31,17 +40,40 @@
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false } },
onShow() { if (!requireSession()) return; this.user = session.currentUser },
computed: {
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')
},
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')
},
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')
}
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() },
onBackPress() {
if (!this.menuOpen) return false
this.$refs.portalHeader.closeMenu()
return true
},
methods: {
async loadWarehouseProfile() {
try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser }
},
openOperation(operation) {
if (!operation.available) return
const module = this.groups.find((group) => group.operations.some((item) => item.name === operation.name))
uni.navigateTo({ url: `/pages/operation/operation?module=${module?.key || 'query'}&name=${encodeURIComponent(operation.name)}` })
uni.navigateTo({ url: `/pages/operation/operation?module=${module?.key || 'query'}&op=${operation.key}` })
}
}
}
+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">柏悦物流海外版</text><text class="login-logo-sub">海外仓现场作业系统</text>
<text class="login-logo-title">{{ $t('app.title') }}</text><text class="login-logo-sub">{{ $t('app.subtitle') }}</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">{{ $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>
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? $t('login.submitting') : $t('login.submit') }}</button>
</view>
</view>
</template>
@@ -24,19 +24,19 @@
methods: {
async login() {
const userId = this.form.userId.trim()
if (!userId) return void (this.error = '请输入工号')
if (!this.form.password) return void (this.error = '请输入密码')
if (!userId) return void (this.error = this.$t('login.usernameRequired'))
if (!this.form.password) return void (this.error = this.$t('login.passwordRequired'))
this.submitting = true
this.error = ''
try {
requireOnline('当前网络不可用,无法登录')
requireOnline(this.$t('login.offline'))
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 ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
this.error = error.network ? this.$t('login.offline') : (error.message || this.$t('login.failed'))
} finally {
this.submitting = false
}
+10 -7
View File
@@ -1,11 +1,11 @@
<template>
<view v-if="user && currentModule" class="page-shell portal-page">
<PortalHeader title="业务模块" :user="user" />
<PortalHeader :title="$t('module.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-arrow">›</text></button></view>
<view class="module-empty-note">当前仅开放拆柜收货,其他作业暂未开放。</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">{{ $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>
</view>
</template>
@@ -17,11 +17,14 @@
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, moduleKey: 'inbound' } },
computed: { currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] } },
computed: {
currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] },
availableCount() { return this.currentModule.operations.filter((operation) => operation.available).length }
},
onLoad(options) { this.moduleKey = options.key || 'inbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser },
methods: {
openOperation(operation) { if (operation.available) uni.navigateTo({ url: `/pages/operation/operation?module=${this.moduleKey}&name=${encodeURIComponent(operation.name)}` }) }
openOperation(operation) { if (operation.available) uni.navigateTo({ url: `/pages/operation/operation?module=${this.moduleKey}&op=${operation.key}` }) }
}
}
</script>
File diff suppressed because one or more lines are too long
+42 -3
View File
@@ -1,7 +1,46 @@
<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">以海外仓排班为准</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">0.1.0</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">退出后会清空海外 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>
<view v-if="user" class="page-shell portal-page">
<PortalHeader :title="$t('profile.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>
<button class="profile-action" @tap="showSettings">{{ $t('profile.settings') }} <text>›</text></button>
<button class="logout-action" @tap="openLogout">{{ $t('profile.logout') }}</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>
</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' }) } } }
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() } },
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') },
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')) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.locale = currentLocale(); 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' }) }
}
}
</script>
+14 -14
View File
@@ -1,6 +1,6 @@
<template>
<view v-if="user" class="page-shell portal-page scanner-test-page">
<PortalHeader title="广播扫码测试" :user="user" />
<PortalHeader :title="$t('scanner.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">最近收到</text>
<text class="scanner-result-value">{{ lastValue || '等待扫描' }}</text>
<text class="scanner-result-meta">{{ lastValue ? `${lastTime} · 本页第 ${scanCount} 次` : '按下 PDA 硬件扫描键' }}</text>
<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>
</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 class="scanner-config-row"><text class="scanner-config-label">{{ $t('scanner.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>
<text class="scanner-history-title">{{ $t('scanner.records') }}</text>
<button class="scanner-clear" :disabled="!history.length" @tap="clearHistory">{{ $t('scanner.clear') }}</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>暂无广播数据</text></view>
<view v-else class="scanner-empty"><text>{{ $t('scanner.empty') }}</text></view>
</view>
</view>
</template>
@@ -57,14 +57,14 @@
},
computed: {
statusTitle() {
if (this.status === 'error') return '接收器启动失败'
if (this.status === 'unsupported') return '当前环境不支持广播'
return this.lastValue ? '已收到扫描广播' : '接收器已启动'
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')
},
statusDetail() {
if (this.status === 'error') return '请检查 PDA 扫描服务设置后重新进入页面'
if (this.status === 'unsupported') return '请在 Android App 真机中测试'
return this.lastValue ? '继续扫描可验证连续接收' : '正在等待硬件扫描结果'
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')
}
},
onShow() {