@@ -1,6 +1,6 @@
< script setup lang = "ts" >
import { computed , onMounted , ref , watch } from 'vue'
import { RefreshCw , Save , Check , Play , Camera , Plus , Pencil , Trash2 } from '@lucide/vue'
import { RefreshCw , Save , Check , Play , Camera , Eye , Plus , Pencil , Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError , getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -14,6 +14,7 @@ import {
completePicking ,
confirmingPreload ,
confirmPreload ,
downloadEvidenceFile ,
getEvidence ,
getOutboundDispatch ,
getPicking ,
@@ -54,6 +55,7 @@ const picking = ref<PickingTask | null>(null)
const evidences = ref < Evidence [ ] > ( [ ] )
const dispatch = ref < OutboundDispatchAggregate | null > ( null )
const loadingRemark = ref ( '' )
const sealNoDraft = ref ( '' )
const splitRemark = ref ( '' )
const busy = ref ( '' )
const loading = ref ( false )
@@ -87,9 +89,12 @@ const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'
const canPicking = computed ( ( ) => ! props . readOnly && access . can ( 'platform:firstleg:inbound:picking:edit' ) )
const canPickingView = computed ( ( ) => access . can ( 'platform:firstleg:inbound:picking:view' ) )
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
const canEvidence = computed ( ( ) => ! props . readOnly && access . can ( 'platform:firstleg:inbound:loading:edit' ) )
const canEvidence = computed ( ( ) => ! props . readOnly && isLoading . value && access . can ( 'platform:firstleg:inbound:loading:edit' ) )
const canEvidenceView = computed ( ( ) => access . can ( 'platform:firstleg:inbound:loading:view' ) )
const canLoadingComplete = computed ( ( ) => ! props . readOnly && ! isLoaded . value && access . can ( 'platform:firstleg:inbound:loading:complete' ) )
const evidenceReady = computed ( ( ) => evidenceTypes . every ( ( { type } ) => activeEvidences ( type ) . some ( ( item ) => (
Boolean ( item . fileId ) && ( type !== 'SEAL' || Boolean ( item . sealNo ? . trim ( ) ) )
) ) ) )
const preloadColumns : DataTableColumn [ ] = [
{ key : 'actions' , label : '操作' , width : 132 , fixed : 'left' , slotName : 'actions' } ,
@@ -147,8 +152,8 @@ function formatVolume(value: number | null) {
return value === null || ! Number . isFinite ( Number ( value ) ) ? '—' : Number ( value ) . toFixed ( 3 )
}
function evidence ( type : EvidenceType ) {
return evidences . value . find ( ( item ) => item . evidenceType === type && item . status === 'ACTIVE' )
function activeEvidences ( type : EvidenceType ) {
return evidences . value . filter ( ( item ) => item . evidenceType === type && item . status === 'ACTIVE' )
}
function idempotentMessage ( action : string ) {
@@ -216,7 +221,9 @@ async function loadLoading() {
picking . value = await getPicking ( props . tenantId , props . inboundId )
} catch ( cause ) {
picking . value = null
messages . push ( cause instanceof Error ? cause . message : '拣货任务加载失败' )
const details = getApiErrorDetails ( cause , '拣货任务加载失败' )
messages . push ( details . message )
errorDetails . value || = details
}
}
if ( canEvidenceView . value ) {
@@ -224,7 +231,9 @@ async function loadLoading() {
evidences . value = ( await getEvidence ( props . tenantId , props . inboundId ) ) . evidences || [ ]
} catch ( cause ) {
evidences . value = [ ]
messages . push ( cause instanceof Error ? cause . message : '装柜证据加载失败' )
const details = getApiErrorDetails ( cause , '装柜证据加载失败' )
messages . push ( details . message )
errorDetails . value || = details
}
}
error . value = messages . join ( '; ' )
@@ -242,7 +251,7 @@ async function loadDispatch() {
async function refresh ( ) {
await loadDispatch ( )
if ( isSplit . value ) await loadPreload ( )
if ( isLoading . value ) await loadLoading ( )
if ( isLoading . value || isLoaded . value ) await loadLoading ( )
}
async function runSmartSplit ( ) {
@@ -309,35 +318,29 @@ async function completePickingTask() {
if ( result ) picking . value = result
}
async function saveEvidence ( type : EvidenceType ) {
const current = evidence ( type )
const result = await run ( ` evidence- ${ type } ` , ( ) => upsertEvidence ( props . tenantId , props . inboundId , {
expectedVersion : current ? . version ? ? 0 ,
evidenceType : type ,
fileId : current ? . fileId || undefined ,
fileName : current ? . fileName || undefined ,
sealNo : current ? . sealNo || undefined ,
remark : current ? . remark || undefined ,
} ) , ` ${ type === 'SEAL' ? '封签' : '证据' } 已保存 ` )
if ( result ? . evidences ) evidences . value = result . evidences . evidences || [ ]
}
async function uploadAndSaveEvidence ( type : EvidenceType , event : Event ) {
const input = event . target as HTMLInputElement
const file = input . files ? . [ 0 ]
input . value = ''
if ( ! file || ! canEvidence . value ) return
const sealNo = sealNoDraft . value . trim ( )
if ( type === 'SEAL' && ! sealNo ) {
error . value = '上传带锁码照前必须填写锁码'
errorDetails . value = null
return
}
const uploaded = await run ( ` evidence-upload- ${ type } ` , ( ) => uploadEvidenceFile ( props . tenantId , props . inboundId , type , file ) , '文件已上传,正在登记证据' )
if ( ! uploaded ) return
const current = evidence ( type )
const result = await run ( ` evidence- ${ type } ` , ( ) => upsertEvidence ( props . tenantId , props . inboundId , {
expectedVersion : current ? . version ? ? 0 ,
expectedVersion : 0 ,
evidenceType : type ,
fileId : uploaded . fileId ,
sealNo : current ? . sealNo || undefined ,
remark : current ? . remark || undefined ,
sealNo : type === 'SEAL' ? sealNo : undefined ,
} ) , ` ${ type === 'SEAL' ? '带锁码照' : '证据' } 已登记 ` )
if ( result ? . evidences ) evidences . value = result . evidences . evidences || [ ]
if ( result ? . evidences ) {
evidences . value = result . evidences . evidences || [ ]
if ( type === 'SEAL' ) sealNoDraft . value = ''
}
}
async function markEvidenceVoided ( item : Evidence ) {
@@ -351,6 +354,25 @@ async function markEvidenceVoided(item: Evidence) {
if ( result ? . evidences ) evidences . value = result . evidences . evidences || [ ]
}
async function previewEvidence ( item : Evidence ) {
if ( ! canEvidenceView . value || ! item . fileId ) return
busy . value = ` evidence-preview- ${ item . id } `
error . value = ''
errorDetails . value = null
try {
const blob = await downloadEvidenceFile ( props . tenantId , props . inboundId , item . fileId )
const url = URL . createObjectURL ( blob )
window . open ( url , '_blank' , 'noopener,noreferrer' )
window . setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 60 _000 )
} catch ( cause ) {
const details = getApiErrorDetails ( cause , '证据照片加载失败,请重试' )
error . value = details . message
errorDetails . value = details
} finally {
busy . value = ''
}
}
async function finishLoading ( ) {
const expectedVersion = inboundExpectedVersion . value
if ( expectedVersion === null ) {
@@ -364,28 +386,6 @@ async function finishLoading() {
if ( result ) await refresh ( )
}
function updateEvidenceField ( type : EvidenceType , field : 'fileName' | 'sealNo' | 'remark' , value : string ) {
const current = evidence ( type )
if ( current ) {
current [ field ] = value || null
return
}
evidences . value . push ( {
id : ` draft- ${ type } ` ,
inboundId : props . inboundId ,
tenantId : props . tenantId ,
evidenceType : type ,
fileId : null ,
fileName : field === 'fileName' ? value : null ,
sealNo : field === 'sealNo' ? value : null ,
remark : field === 'remark' ? value : null ,
version : 0 ,
status : 'ACTIVE' ,
createdAt : null ,
updatedAt : null ,
} )
}
watch ( ( ) => props . mainStatus , ( ) => { void refresh ( ) } )
onMounted ( ( ) => { void refresh ( ) } )
< / script >
@@ -404,7 +404,7 @@ onMounted(() => { void refresh() })
< / el-alert >
< el-alert v-if = "notice" class="workflow-alert" type="success" :closable="false" show -icon > { { notice } } < / el-alert >
< el-alert v-if = "busy" class="workflow-alert" type="info" :closable="false" > {{ idempotentMessage ( busy ) }} < / el -alert >
< el-alert v-if = "props.readOnly" class="workflow-alert" type="info" :closable="false" show -icon > 当前仅开放进度查询 ; 五图取证和装柜完成门禁接通后再开放操作 。 < / el-alert >
< el-alert v-if = "props.readOnly" class="workflow-alert" type="info" :closable="false" show -icon > 当前为只读查看 , 不能修改作业数据 。 < / el-alert >
< el-card v-if = "dispatch" shadow="never" class="workflow-card" >
< template # header > < div class = "card-title" > < span > 子流程与门禁摘要 < / span > < el-tag : type = "dispatch.gate?.passed ? 'success' : 'warning'" > { { dispatch . gate ? . passed ? '已通过' : '待补齐' } } < / el-tag > < / div > < / template >
@@ -472,29 +472,37 @@ onMounted(() => { void refresh() })
< el-input v-model = "loadingRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" / >
< / el-card >
< el-card shadow = "never" class = "workflow-card" >
< template # header > < div class = "card-title" > < span > 装柜五类证据与锁码 < / span > < el-tag type = "info" > 五类必传 < / el-tag > < / div > < / template >
< / template >
< el-card v-if = "isLoading || isLoaded" v-loading="loading" shadow="never" class="workflow-card" >
< template # header > < div class = "card-title" > < span > 装柜五类证据与锁码 < / span > < el-tag : type = "evidenceReady ? 'success' : 'info'" > { { evidenceReady ? '证据齐全' : '五类必传' } } < / el-tag > < / div > < / template >
< el-alert v-if = "!canEvidenceView" type="warning" :closable="false" show -icon > 当前账号无装柜证据查询权限 。 < / el-alert >
< div class = "evidence-grid" >
< div v-for = "entry in evidenceTypes" :key="entry.type" class="evidence-item" >
< strong > < Camera :size = "15" / > { { entry . label } } < / strong >
< strong > < Camera :size = "15" / > { { entry . label } } < el-tag size = "small" : type = "activeEvidences(entry.type).length ? 'success' : 'danger'" > { { activeEvidences ( entry . type ) . length } } 张 < / el-tag > < / strong >
< div class = "evidence-control" >
< el-input v-if = "entry.type === 'SEAL'" :disabled="props.readOnly" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="锁码" placeholder="锁码(带锁码照必填)" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event) " / >
< span v-if = "e vidence(entry. type)?. fileId" class="workflow-hint " > {{ evidence ( entry.type ) ? .fileName | | ' 已上传文件 ' }} < / span >
< label v-if = "canEvidence" class="file-picker"><Camera :size="14" />上传照片<input type="file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event)" / > < / label >
< el-input v-if = "entry.type === 'SEAL' && canEvidence" v-model="sealNoDraft" maxlength="64" show-word-limit aria-label="下一张照片的 锁码" placeholder="先填写锁码,再上传带锁码照 " / >
< label v-if = "canE vidence" class="file-picker"><Camera :size="14" />追加照片<input type=" file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event) " / > < / label >
< / div >
< div class = "evidence-action s" >
< el-button v-if = "entry.type === 'SEAL'" plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}` " @ click="saveEvidence(entry.type)" > 保存锁码 < / el -button >
< el-button v-for = "item in evidences.filter((candidate) => candidate.evidenceType === entry.type && candidate.status === 'ACTIVE')" :key="String(item.id)" link type="danger" :disabled="!canEvidence || Boolean(busy)" @click="markEvidenceVoided(item)" > 作废 < / el -button >
< div v-if = "canEvidenceView" class=" evidence-file s" >
< div v-for = "item in activeEvidences(entry.type)" :key="String(item.id) " class="evidence-file" >
< span > { { item . fileName || ` 证据 # ${ item . id } ` } } < template v-if = "item.sealNo" > · 锁码 {{ item.sealNo }} < / template > < / span >
< div class = "evidence-file__actions" >
< el-button link type = "primary" :disabled = "Boolean(busy)" @click ="previewEvidence(item)" > < Eye :size = "14" / > 查看 < / el-button >
< el-button v-if = "canEvidence" link type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)" > 作废 < / el -button >
< / div >
< / div >
< span v-if = "activeEvidences(entry.type).length === 0" class="workflow-hint" > 尚未上传有效照片 < / span >
< / div >
< / div >
< / div >
< p class = "workflow-hint" > 五类证据各至少一张有效照片 ; 锁码文本与带锁码照同时必填 。 错误照片只能作废并保留审计记录 。 < / p >
< div class = "workflow-actions" >
< el-button type = "primary" : disabled = "!canLoadingComplete || inboundExpectedVersion === null || Boolean(busy)" : loading = "busy === 'loading-complete'" @click ="finishLoading" > < Check :size = "15" / > 完成装柜 < / el-button >
< div v-if = "isLoading" class=" workflow-actions" >
< el -button type = "primary" : disabled = "!canLoadingComplete || !evidenceReady || inboundExpectedVersion === null || Boolean(busy)" : loading = "busy === 'loading-complete'" @click ="finishLoading" > < Check :size = "15" / > 完成装柜 < / el-button >
< span v-if = "!evidenceReady" class="workflow-hint" > 五类有效照片及锁码齐全后可提交 ; 其他门禁仍由服务端校验 < / span >
< span v-if = "inboundExpectedVersion === null" class="workflow-hint" > 缺少入库版本 , 刷新订单详情后可提交 < / span >
< / div >
< / el -card >
< / template >
< / el -card >
< el-alert v-if = "isLoaded" class="workflow-alert" type="success" :closable="false" show -icon >
已装柜完成 。 国内端不提供在线冲正 、 回退或推进派送完成 , 后续状态仅接受海外 / 尾程可信回传 。
@@ -538,10 +546,13 @@ onMounted(() => { void refresh() })
. workflow - actions { margin - top : 14 px ; }
. workflow - remark { margin - top : 10 px ; }
. evidence - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; }
. evidence - item { display : grid ; grid - template - columns : minmax ( 100 px , auto ) minmax ( 0 , 1 fr ) auto ; gap : 8 px ; align - items : center ; }
. evidence - item { display : grid ; min - width : 0 ; gap : 8 px ; align - content : start ; padding : 12 px ; border : 1 px solid var ( -- cc - border ) ; border - radius : 6 px ; }
. evidence - item strong { display : inline - flex ; align - items : center ; gap : 5 px ; white - space : nowrap ; }
. evidence - control { display : flex ; min - width : 0 ; align - items : center ; gap : 8 px ; }
. evidence - actions { display : flex ; align - items : center ; gap : 4 px ; }
. evidence - files { display : grid ; gap : 4 px ; }
. evidence - file { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 8 px ; color : var ( -- cc - text - secondary , # 64748 b ) ; font - size : 12 px ; }
. evidence - file span { min - width : 0 ; overflow - wrap : anywhere ; }
. evidence - file _ _actions { display : flex ; flex : 0 0 auto ; align - items : center ; gap : 4 px ; }
. gate - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 8 px ; }
. gate - grid > div { display : flex ; min - height : 34 px ; align - items : center ; justify - content : space - between ; gap : 8 px ; padding : 7 px 9 px ; border : 1 px solid var ( -- cc - border ) ; border - radius : 5 px ; }
. gate - grid span { color : var ( -- cc - text - secondary , # 64748 b ) ; font - size : 12 px ; }