优化
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|
||||
## 开发与发布边界
|
||||
|
||||
- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令;H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
|
||||
- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。真实作业包括一件代发合托和集装箱拣货;项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令。H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
|
||||
- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。
|
||||
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
|
||||
- H5 调试使用 HBuilderX 5.15 自带的 uni-app Vue 3 编译器;Android 发布流程仍待明确后补充准确的依赖版本、打包命令,并同步更新 `README.md` 与本文件。
|
||||
@@ -55,6 +55,7 @@
|
||||
| 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 |
|
||||
| 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 |
|
||||
| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 |
|
||||
| 一件代发合托 | `pages/dropship-tasks/dropship-tasks.vue`、`pages/dropship-palletization/dropship-palletization.vue` | `api/pda-dropship.js`;测量完成后独立任务按箱码清点,可绑定既有 DP 托盘或系统发新托盘,全部归托后逐托封托;公网前缀 `/api/platform/v1/first-leg/pda-dropship/**` |
|
||||
| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId |
|
||||
| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` |
|
||||
| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs` |
|
||||
@@ -69,7 +70,7 @@
|
||||
|
||||
| 验收域 | PDA 范围与代码入口 | 验收首读 |
|
||||
|---|---|---|---|
|
||||
| 作业全链路 | 登录、工作台、扫码拣货、装柜证据、草稿持久化、批量提交、异常和在线更新 | `pages/login/`、`pages/home/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`、`utils/scan-broadcast.js`、`pages.json` | 按业务选择 `../../docs/订单与客户/`、`../../docs/入库测量与计费/`、`../../docs/国内货柜履约/` 和 `README.md` 的契约与验收规则 |
|
||||
| 作业全链路 | 登录、工作台、一件代发扫码清点/归托/封托、集装箱扫码拣货、装柜证据、草稿持久化、批量提交、异常和在线更新 | `pages/login/`、`pages/home/`、`pages/dropship-tasks/`、`pages/dropship-palletization/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`、`utils/scan-broadcast.js`、`pages.json` | 按业务选择 `../../docs/订单与客户/`、`../../docs/入库测量与计费/`、`../../docs/国内货柜履约/` 和 `README.md` 的契约与验收规则 |
|
||||
|
||||
作业边界:PDA 不承担客户工作台或后台配置;服务端状态、权限、幂等和 `LOADED` 门禁是验收事实来源。发布采用既定 uni-app 在线更新安排。
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiRequest } from '@/api/http.js'
|
||||
|
||||
const ROOT = '/first-leg/pda-dropship'
|
||||
|
||||
function operationId(action, taskId) {
|
||||
const random = Math.random().toString(36).slice(2, 10)
|
||||
return `pda-dropship-${action}-${taskId}-${Date.now()}-${random}`.slice(0, 128)
|
||||
}
|
||||
|
||||
export function listDropshipPalletizationTasks(keyword = '') {
|
||||
const query = String(keyword || '').trim()
|
||||
return apiRequest({ url: `${ROOT}/tasks/pending${query ? `?keyword=${encodeURIComponent(query)}` : ''}`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function getDropshipPalletizationTask(taskId, warehouseId) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function claimDropshipPalletizationTask(taskId, warehouseId) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/claim`, method: 'POST', data: { warehouseId } })
|
||||
}
|
||||
|
||||
export function resolveDropshipCarton(taskId, warehouseId, boxCode) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/resolve?warehouseId=${encodeURIComponent(warehouseId)}&boxCode=${encodeURIComponent(String(boxCode || '').trim())}`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function countDropshipCarton(taskId, payload) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/count`, method: 'POST', data: { ...payload, operationId: payload.operationId || operationId('count', taskId) } })
|
||||
}
|
||||
|
||||
export function bindDropshipPendingCartons(taskId, warehouseId, palletNo) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', data: { warehouseId, palletNo: String(palletNo || '').trim().toUpperCase(), operationId: operationId('bind', taskId) } })
|
||||
}
|
||||
|
||||
export function createAndBindDropshipPallet(taskId, warehouseId) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/create-and-bind`, method: 'POST', data: { warehouseId, operationId: operationId('create-bind', taskId) } })
|
||||
}
|
||||
|
||||
export function sealDropshipPallet(taskId, warehouseId, pallet) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/seal`, method: 'POST', data: { warehouseId, expectedVersion: pallet.version, operationId: operationId('seal', taskId) } })
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export const MODULES = [
|
||||
{ name: '入库质检', icon: '检', description: '登记质检结果与异常信息' }
|
||||
] },
|
||||
{ key: 'outbound', name: '出库', icon: '出', description: '拣货、复核与装箱', meta: '3 个待办', operations: [
|
||||
{ name: '一件代发合托', icon: '托', description: '逐箱清点并生成或绑定 DP 托盘', available: true },
|
||||
{ name: '集装箱拣货', icon: '拣', description: '按箱号扫描并确认拣货', available: true },
|
||||
{ name: '出库复核', icon: '核', description: '复核拣货商品与出库数量' },
|
||||
{ name: '装箱交接', icon: '装', description: '完成装箱并登记交接信息' }
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "业务模块", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "现场作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/tasks/tasks", "style": { "navigationStyle": "custom", "navigationBarTitleText": "拣货任务", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/dropship-tasks/dropship-tasks", "style": { "navigationStyle": "custom", "navigationBarTitleText": "一件代发合托", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/dropship-palletization/dropship-palletization", "style": { "navigationStyle": "custom", "navigationBarTitleText": "合托作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } },
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell palletization-page">
|
||||
<PortalHeader title="合托作业" :user="user" :fallback-url="'/pages/dropship-tasks/dropship-tasks'" />
|
||||
<view class="content">
|
||||
<view v-if="error" class="message error" role="alert">{{ error }}</view>
|
||||
<view v-if="notice" class="message success" role="status">{{ notice }}</view>
|
||||
<view v-if="loading && !task" class="state">正在加载合托任务…</view>
|
||||
<template v-if="task">
|
||||
<view class="summary-card">
|
||||
<view class="summary-head"><view><text class="eyebrow">入仓码</text><text class="inbound-code">{{ task.inboundCode || '—' }}</text></view><text class="status">{{ statusLabel }}</text></view>
|
||||
<text class="customer">{{ task.customerName || '客户未关联' }} · {{ task.warehouseName || '仓库未关联' }}</text>
|
||||
<view class="metrics">
|
||||
<view><strong>{{ task.totalCartons || 0 }}</strong><text>总箱</text></view>
|
||||
<view><strong>{{ task.countedCartons || 0 }}</strong><text>已清点</text></view>
|
||||
<view><strong>{{ task.boundCartons || 0 }}</strong><text>已归托</text></view>
|
||||
<view><strong>{{ task.unboundCartons || 0 }}</strong><text>待归托</text></view>
|
||||
</view>
|
||||
<button v-if="task.taskStatus === 'PENDING'" class="primary-button" :disabled="submitting" @tap="claimTask">领取并开始合托</button>
|
||||
</view>
|
||||
|
||||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
||||
<text class="section-title">1. 扫箱清点</text>
|
||||
<text class="section-desc">扫描箱唛,核对 SKU 与本箱实际件数。</text>
|
||||
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
|
||||
<view v-if="carton" class="carton-card">
|
||||
<view class="carton-head"><text>{{ carton.boxCode }}</text><text>{{ carton.countStatus === 'UNCOUNTED' ? '待清点' : '已处理' }}</text></view>
|
||||
<text class="carton-line">{{ carton.skuCode || 'SKU —' }} · {{ carton.cargoName || '品名 —' }}</text>
|
||||
<text class="carton-line">计划 {{ carton.expectedSkuQuantity || 0 }} 件</text>
|
||||
<view class="field"><text>实际件数</text><input v-model="actualQuantity" type="number" aria-label="本箱实际件数" /></view>
|
||||
<view v-if="quantityDiffers" class="field stacked"><text>差异说明</text><textarea v-model="discrepancyRemark" maxlength="500" placeholder="数量不一致时必填" /></view>
|
||||
<button class="primary-button" :disabled="!canCount || submitting" @tap="countCarton">确认本箱并进入待归托区</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
||||
<text class="section-title">2. 归托</text>
|
||||
<text class="section-desc">把已清点待归托箱绑定到现有 DP 托盘,或由系统生成新托盘。</text>
|
||||
<ScanTargetInput v-model="palletNo" name="palletNo" label="托盘码" placeholder="扫描 DP-YYMMDD-NNNN" :active="activeScan === 'palletNo'" @activate="activeScan = 'palletNo'" @submit="bindPending" />
|
||||
<button class="secondary-button" :disabled="!validPalletNo || !task.unboundCartons || submitting" @tap="bindPending">绑定到扫描托盘</button>
|
||||
<button class="primary-button" :disabled="!task.unboundCartons || submitting" @tap="createAndBind">生成 DP 托盘并绑定</button>
|
||||
</view>
|
||||
|
||||
<view class="section-card">
|
||||
<view class="section-heading"><view><text class="section-title">3. 托盘与封托</text><text class="section-desc">所有箱清点并归托后,逐托确认封托。</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
|
||||
<view v-if="!task.pallets || !task.pallets.length" class="empty">暂无托盘</view>
|
||||
<view v-for="pallet in task.pallets" :key="pallet.palletId" class="pallet-card">
|
||||
<view class="pallet-head"><text class="pallet-code">{{ pallet.palletNo }}</text><text class="pallet-status" :class="pallet.status === 'SEALED' ? 'sealed' : ''">{{ pallet.status === 'SEALED' ? '已封托' : '待封托' }}</text></view>
|
||||
<text class="pallet-meta">{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU · {{ pallet.weightKg || 0 }} kg · {{ pallet.volumeCbm || 0 }} m³</text>
|
||||
<button v-if="pallet.status === 'OPEN'" class="primary-button" :disabled="submitting || !canSeal" @tap="sealPallet(pallet)">确认封托</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, createAndBindDropshipPallet, getDropshipPalletizationTask, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { formatApiError } from '@/utils/user-facing.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, boxCode: '', palletNo: '', carton: null, actualQuantity: '', discrepancyRemark: '', activeScan: 'boxCode', loading: false, submitting: false, error: '', notice: '' } },
|
||||
computed: {
|
||||
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
|
||||
quantityDiffers() { return this.carton && Number(this.actualQuantity) !== Number(this.carton.expectedSkuQuantity) },
|
||||
canCount() { return Boolean(this.carton?.canCount && Number(this.actualQuantity) > 0 && (!this.quantityDiffers || this.discrepancyRemark.trim())) },
|
||||
validPalletNo() { return /^DP-\d{6}-\d{4}$/.test(this.palletNo.trim().toUpperCase()) },
|
||||
canSeal() { return Number(this.task?.countedCartons || 0) === Number(this.task?.totalCartons || 0) && Number(this.task?.unboundCartons || 0) === 0 }
|
||||
},
|
||||
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); this.loadTask() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleScan(value) { if (this.activeScan === 'palletNo') { this.palletNo = String(value).trim().toUpperCase(); this.bindPending() } else { this.boxCode = String(value).trim().toUpperCase(); this.resolveCarton() } },
|
||||
resetMessages() { this.error = ''; this.notice = '' },
|
||||
async loadTask() {
|
||||
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
|
||||
this.loading = true; this.resetMessages()
|
||||
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId) }
|
||||
catch (error) { this.error = formatApiError(error, '合托任务加载失败') }
|
||||
finally { this.loading = false }
|
||||
},
|
||||
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, Number(this.warehouseId)), '任务领取成功') },
|
||||
async resolveCarton(value) {
|
||||
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
|
||||
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
|
||||
try { this.carton = await resolveDropshipCarton(this.taskId, this.warehouseId, code); this.actualQuantity = String(this.carton.expectedSkuQuantity || '') }
|
||||
catch (error) { this.error = formatApiError(error, '箱码解析失败') }
|
||||
finally { this.loading = false }
|
||||
},
|
||||
async countCarton() {
|
||||
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: Number(this.warehouseId), boxCode: this.carton.boxCode, actualSkuQuantity: Number(this.actualQuantity), discrepancyAction: this.quantityDiffers ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.quantityDiffers ? this.discrepancyRemark.trim() : null, palletNo: null }), '本箱已清点,等待归托')
|
||||
if (result) { this.boxCode = ''; this.carton = null; this.actualQuantity = ''; this.discrepancyRemark = '' }
|
||||
},
|
||||
async bindPending(value) { const code = String(value || this.palletNo || '').trim().toUpperCase(); if (!/^DP-\d{6}-\d{4}$/.test(code)) { this.error = '请输入正确的 DP 托盘码'; return }; this.palletNo = code; await this.runMutation(() => bindDropshipPendingCartons(this.taskId, Number(this.warehouseId), code), '待归托箱已绑定') },
|
||||
async createAndBind() { await this.runMutation(() => createAndBindDropshipPallet(this.taskId, Number(this.warehouseId)), '已生成 DP 托盘并绑定') },
|
||||
async sealPallet(pallet) { await this.runMutation(() => sealDropshipPallet(this.taskId, Number(this.warehouseId), pallet), `${pallet.palletNo} 已封托`) },
|
||||
async runMutation(action, message) {
|
||||
if (this.submitting) return null
|
||||
this.submitting = true; this.resetMessages()
|
||||
try {
|
||||
const result = await action()
|
||||
this.task = result?.taskId && Array.isArray(result?.pallets)
|
||||
? result
|
||||
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
|
||||
this.notice = message
|
||||
return result
|
||||
}
|
||||
catch (error) { this.error = formatApiError(error, '操作失败'); return null }
|
||||
finally { this.submitting = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 12px 12px 42px; }
|
||||
.state, .message { margin-bottom: 10px; padding: 12px; border-radius: 9px; color: #667085; font-size: 12px; line-height: 18px; text-align: center; background: #fff; }
|
||||
.message.error { color: #b42318; background: #fff1f0; }.message.success { color: #067647; background: #ecfdf3; }
|
||||
.summary-card, .section-card { margin-bottom: 12px; padding: 13px; border: 1px solid #dce6f1; border-radius: 12px; background: #fff; box-shadow: 0 2px 8px rgba(23,55,93,.05); }
|
||||
.summary-head, .section-heading, .pallet-head, .carton-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.summary-head > view { display: flex; min-width: 0; flex-direction: column; }.eyebrow { color: #667085; font-size: 10px; }.inbound-code { margin-top: 2px; color: #12233f; font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 10px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
|
||||
.customer { display: block; margin-top: 6px; color: #667085; font-size: 11px; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(4,1fr); margin: 12px 0; padding: 10px 0; border-top: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5; }.metrics view { display: flex; flex-direction: column; align-items: center; border-right: 1px solid #edf1f5; }.metrics view:last-child { border: 0; }.metrics strong { font-size: 17px; }.metrics text { margin-top: 2px; color: #667085; font-size: 10px; }
|
||||
.section-title { display: block; color: #12233f; font-size: 16px; font-weight: 700; }.section-desc { display: block; margin: 3px 0 11px; color: #667085; font-size: 11px; line-height: 17px; }.section-heading > view { min-width: 0; }
|
||||
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
|
||||
.carton-card, .pallet-card { margin-top: 11px; padding: 11px; border: 1px solid #e3eaf2; border-radius: 9px; background: #f8fbff; }.carton-head { color: #12233f; font-size: 13px; font-weight: 700; }.carton-line, .pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 11px; line-height: 17px; }
|
||||
.field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; color: #344054; font-size: 12px; }.field input { box-sizing: border-box; width: 120px; height: 42px; padding: 0 10px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }.field.stacked { align-items: stretch; flex-direction: column; gap: 6px; }.field textarea { box-sizing: border-box; width: 100%; height: 70px; padding: 9px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }
|
||||
.pallet-card { background: #fff; }.pallet-code { color: #12233f; font-size: 15px; font-weight: 700; overflow-wrap: anywhere; }.text-button { min-width: 58px; height: 40px; border: 0; color: #155eef; font-size: 12px; line-height: 40px; background: transparent; }.empty { padding: 18px 0 8px; color: #98a2b3; font-size: 12px; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell dropship-task-page">
|
||||
<PortalHeader title="一件代发合托" :user="user" :fallback-url="'/pages/home/home'" />
|
||||
<view class="content">
|
||||
<view class="search-card">
|
||||
<ScanTargetInput v-model="keyword" name="taskKeyword" label="入仓码查询" placeholder="扫描或输入入仓码" :active="true" @submit="loadTasks" />
|
||||
<button class="primary-button search-button" :disabled="loading" @tap="loadTasks">{{ loading ? '查询中…' : '查询任务' }}</button>
|
||||
</view>
|
||||
<view class="heading-row">
|
||||
<view><text class="heading">待合托任务</text><text class="summary">{{ loading ? '正在同步' : `共 ${tasks.length} 个任务` }}</text></view>
|
||||
<button class="refresh-button" :disabled="loading" @tap="refresh">刷新</button>
|
||||
</view>
|
||||
<view v-if="error" class="state error" role="alert">{{ error }}</view>
|
||||
<view v-else-if="!loading && !tasks.length" class="state">暂无待合托任务</view>
|
||||
<view class="task-list">
|
||||
<button v-for="task in tasks" :key="task.taskId" class="task-card" :aria-label="`进入合托任务,入仓码 ${task.inboundCode || '未关联'}`" @tap="openTask(task)">
|
||||
<view class="task-head"><text class="business-code">{{ task.inboundCode || '入仓码 —' }}</text><text class="status" :class="task.taskStatus === 'CLAIMED' ? 'claimed' : ''">{{ statusLabel(task.taskStatus) }}</text></view>
|
||||
<text class="customer">{{ task.customerName || '客户未关联' }}</text>
|
||||
<view class="metrics">
|
||||
<view><strong>{{ task.totalCartons || 0 }}</strong><text>总箱</text></view>
|
||||
<view><strong>{{ task.countedCartons || 0 }}</strong><text>已清点</text></view>
|
||||
<view><strong>{{ task.boundCartons || 0 }}</strong><text>已归托</text></view>
|
||||
<view><strong>{{ task.unboundCartons || 0 }}</strong><text>待归托</text></view>
|
||||
</view>
|
||||
<view class="task-foot"><text>仓库 {{ task.warehouseName || '—' }}</text><text class="enter">进入作业 ›</text></view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { listDropshipPalletizationTasks } from '@/api/pda-dropship.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { formatApiError } from '@/utils/user-facing.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '' } },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
|
||||
methods: {
|
||||
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
|
||||
refresh() { this.keyword = ''; this.loadTasks() },
|
||||
async loadTasks() {
|
||||
if (this.loading) return
|
||||
this.loading = true; this.error = ''
|
||||
try {
|
||||
const records = await listDropshipPalletizationTasks(this.keyword)
|
||||
this.tasks = Array.isArray(records) ? records : []
|
||||
}
|
||||
catch (error) { this.tasks = []; this.error = formatApiError(error, '合托任务加载失败') }
|
||||
finally { this.loading = false }
|
||||
},
|
||||
openTask(task) { uni.navigateTo({ url: `/pages/dropship-palletization/dropship-palletization?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` }) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 12px 12px 40px; }
|
||||
.search-card { padding: 12px; border: 1px solid #dce6f1; border-radius: 12px; background: #fff; box-shadow: 0 2px 8px rgba(23,55,93,.05); }
|
||||
.primary-button { height: 46px; border: 0; border-radius: 9px; color: #fff; font-size: 15px; font-weight: 700; line-height: 46px; background: #1a73e8; }
|
||||
.search-button { width: 100%; margin-top: 10px; }
|
||||
.heading-row { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 10px; }
|
||||
.heading-row > view { display: flex; flex-direction: column; }
|
||||
.heading { color: #12233f; font-size: 18px; font-weight: 700; }
|
||||
.summary { margin-top: 2px; color: #667085; font-size: 11px; }
|
||||
.refresh-button { min-width: 72px; height: 42px; border: 0; border-radius: 9px; color: #155eef; font-size: 14px; line-height: 42px; background: #eaf2ff; }
|
||||
.state { padding: 24px 12px; border-radius: 10px; color: #667085; font-size: 13px; text-align: center; background: #fff; }
|
||||
.state.error { color: #b42318; background: #fff1f0; }
|
||||
.task-card { box-sizing: border-box; width: 100%; margin: 0 0 12px; padding: 13px; border: 1px solid #dce6f1; border-radius: 12px; color: #12233f; text-align: left; background: #fff; box-shadow: 0 2px 8px rgba(23,55,93,.05); }
|
||||
.task-head, .task-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.business-code { min-width: 0; font-size: 17px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #9a5b13; font-size: 10px; background: #fff4e5; }
|
||||
.status.claimed { color: #155eef; background: #eff4ff; }
|
||||
.customer { display: block; margin-top: 5px; color: #667085; font-size: 11px; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 12px; padding: 10px 0; border-top: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5; }
|
||||
.metrics view { display: flex; align-items: center; flex-direction: column; border-right: 1px solid #edf1f5; }
|
||||
.metrics view:last-child { border-right: 0; }
|
||||
.metrics strong { font-size: 17px; }
|
||||
.metrics text { margin-top: 2px; color: #667085; font-size: 10px; }
|
||||
.task-foot { margin-top: 10px; color: #667085; font-size: 11px; }
|
||||
.enter { min-height: 40px; color: #155eef; font-size: 12px; font-weight: 700; line-height: 40px; }
|
||||
</style>
|
||||
+6
-2
@@ -24,7 +24,7 @@
|
||||
|
||||
const ICON_TYPES = {
|
||||
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
|
||||
'集装箱拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
|
||||
'一件代发合托': 'map', '集装箱拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
|
||||
'库存盘点': 'list', '商品移库': 'loop', '库存调整': 'compose',
|
||||
'商品查询': 'search', '库位查询': 'location', '任务查询': 'flag'
|
||||
}
|
||||
@@ -46,7 +46,11 @@
|
||||
return true
|
||||
},
|
||||
methods: {
|
||||
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) }
|
||||
openOperation(operation) {
|
||||
if (!operation.available) return
|
||||
const routes = { '一件代发合托': '/pages/dropship-tasks/dropship-tasks', '集装箱拣货': '/pages/tasks/tasks' }
|
||||
uni.navigateTo({ url: routes[operation.name] || '/pages/operation/operation' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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 class="module-empty-note">当前开放“一件代发合托”和“集装箱拣货”两条真实作业流程。</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -22,7 +22,11 @@
|
||||
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: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) }
|
||||
openOperation(operation) {
|
||||
if (!operation.available) return
|
||||
const routes = { '一件代发合托': '/pages/dropship-tasks/dropship-tasks', '集装箱拣货': '/pages/tasks/tasks' }
|
||||
uni.navigateTo({ url: routes[operation.name] || '/pages/operation/operation' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('domestic workbench exposes a real one-click dropship palletization entry', async () => {
|
||||
const [data, home, modulePage, pages] = await Promise.all([
|
||||
read('../data/demo-data.js'), read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages.json')
|
||||
])
|
||||
assert.match(data, /name: '一件代发合托'[\s\S]*?available: true/)
|
||||
assert.match(home, /'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'/)
|
||||
assert.match(modulePage, /'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'/)
|
||||
assert.match(pages, /pages\/dropship-tasks\/dropship-tasks/)
|
||||
assert.match(pages, /pages\/dropship-palletization\/dropship-palletization/)
|
||||
})
|
||||
|
||||
test('dropship palletization adapter uses only the dedicated production PDA contract', async () => {
|
||||
const api = await read('../api/pda-dropship.js')
|
||||
for (const route of [
|
||||
'/tasks/pending', '/cartons/resolve', '/cartons/count', '/pallets/bind-pending',
|
||||
'/pallets/create-and-bind', '/seal'
|
||||
]) assert.match(api, new RegExp(route.replaceAll('/', '\\/')))
|
||||
assert.match(api, /expectedVersion: pallet\.version/)
|
||||
assert.match(api, /operationId\('count'/)
|
||||
assert.match(api, /operationId\('bind'/)
|
||||
assert.match(api, /operationId\('create-bind'/)
|
||||
assert.match(api, /operationId\('seal'/)
|
||||
assert.doesNotMatch(api, /customerId|tenantId|inboundId/)
|
||||
})
|
||||
|
||||
test('palletization UI supports scan, count, existing or generated pallet, and seal', async () => {
|
||||
const [tasks, work] = await Promise.all([
|
||||
read('../pages/dropship-tasks/dropship-tasks.vue'),
|
||||
read('../pages/dropship-palletization/dropship-palletization.vue')
|
||||
])
|
||||
for (const text of ['待合托任务', '入仓码', '进入作业']) assert.match(tasks, new RegExp(text))
|
||||
for (const text of ['领取并开始合托', '扫箱清点', '差异说明', '绑定到扫描托盘', '生成 DP 托盘并绑定', '确认封托']) assert.match(work, new RegExp(text))
|
||||
assert.match(work, /startScanBroadcast/)
|
||||
assert.match(work, /quantityDiffers[\s\S]*?ACCEPT_VARIANCE/)
|
||||
assert.match(work, /canSeal\(\)[\s\S]*?countedCartons[\s\S]*?totalCartons[\s\S]*?unboundCartons/)
|
||||
assert.match(work, /formatApiError/)
|
||||
assert.doesNotMatch(work, /taskId\s*\}\}|warehouseId\s*\}\}|inboundId\s*\}\}/)
|
||||
})
|
||||
@@ -10,7 +10,8 @@ test('page configuration starts at login and registers independent PDA routes',
|
||||
assert.equal(pages.pages[0].style.navigationStyle, 'custom')
|
||||
assert.deepEqual(pages.pages.map((page) => page.path), [
|
||||
'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation',
|
||||
'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking'
|
||||
'pages/tasks/tasks', 'pages/dropship-tasks/dropship-tasks', 'pages/dropship-palletization/dropship-palletization',
|
||||
'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -87,15 +88,16 @@ test('workbench, module, history and profile are independent sources', async ()
|
||||
for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', '开始作业', 'uni-icons']) assert.match(home, new RegExp(text))
|
||||
assert.doesNotMatch(home, /getPickingOverview|pendingBoxCount|箱待拣/)
|
||||
assert.doesNotMatch(home, /BottomNav|status-strip|task-card/)
|
||||
for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text))
|
||||
for (const text of ['业务模块', '业务功能', '一件代发合托', '集装箱拣货']) assert.match(module, new RegExp(text))
|
||||
assert.match(home, /:disabled="!operation\.available"/)
|
||||
assert.match(home, /home-operation-disabled/)
|
||||
assert.match(home, /home-operation-meta home-operation-pending/)
|
||||
assert.match(home, /home-operation-meta home-operation-unavailable/)
|
||||
assert.match(home, /openOperation\(operation\) \{ if \(operation\.available\) uni\.navigateTo/)
|
||||
assert.match(home, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?routes\[operation\.name\]/)
|
||||
assert.doesNotMatch(home, /pages\/operation\/operation\?module=/)
|
||||
assert.match(module, /:disabled="!operation\.available"/)
|
||||
assert.match(module, /operation-item-disabled/)
|
||||
assert.match(module, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?routes\[operation\.name\]/)
|
||||
assert.doesNotMatch(module, /pages\/operation\/operation\?module=/)
|
||||
assert.match(history, /作业记录/)
|
||||
assert.match(profile, /个人中心/)
|
||||
|
||||
Reference in New Issue
Block a user