2026-08-03 11:44:50 +08:00
< script setup lang = "ts" >
2026-10-10 02:19:39 +08:00
import { computed , onBeforeUnmount , reactive , ref , shallowRef , watch } from 'vue'
import { useRoute , useRouter } from 'vue-router'
2026-10-27 17:39:28 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-10-10 02:19:39 +08:00
import { ApiError } from '@/api/types'
import { accessTokenIdentity } from '@/api/session'
import { useAuthStore } from '@/modules/auth/public'
import DataTable , { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/components/common/QueryToolbar.vue'
import type { TableAction } from '@/components/common/tableActions'
import ProcurementDetailPanel from '../components/ProcurementDetailPanel.vue'
2026-10-27 17:39:28 +08:00
import { confirmProcurement , createProcurement , getProcurement , getProcurementWorkflow , listProcurements , type ProcurementDetail , type ProcurementSummary , type ProcurementWorkflow } from '../api/procurement'
2026-10-28 16:56:33 +08:00
import { procurementDirections , directionLabel , regionText , buildProcurementCreate , dateTime , emptyProcurementDraft , money , procurementStatuses , statusLabel , statusTone } from '../procurementPresentation'
2026-10-22 23:06:02 +08:00
import { procurementRequestKey } from '../procurementSubmission'
2026-10-10 02:19:39 +08:00
const props = withDefaults ( defineProps < { createPage ?: boolean ; detailPage ?: boolean } > (), { createPage : false , detailPage : false })
const auth = useAuthStore (), route = useRoute (), router = useRouter ()
const mode = computed (() => props . detailPage ? 'detail' : props . createPage ? 'create' : 'list' )
const orderNo = computed (() => typeof route . params . orderNo === 'string' ? route . params . orderNo : '' )
const canCreate = computed (() => auth . hasPermission ( 'merchant:cross-border:procurement:create' ))
const canList = computed (() => auth . hasPermission ( 'merchant:cross-border:procurement:list' ))
const identity = computed (() => auth . token ? accessTokenIdentity ( auth . token ) : null )
const companyName = computed (() => auth . profile . tenantName || ( auth . user ? . tenantName && ! /^企业租户 \d+$/ . test ( auth . user . tenantName ) ? auth . user . tenantName : '' ))
const form = reactive ( emptyProcurementDraft ())
2026-10-22 23:06:02 +08:00
const submitError = ref ( '' ), submittedOrder = ref ( '' )
2026-10-10 02:19:39 +08:00
const submitting = ref ( false ), loading = ref ( false ), error = ref ( '' )
const detail = shallowRef < ProcurementDetail | null >( null )
2026-10-27 17:39:28 +08:00
const workflow = shallowRef < ProcurementWorkflow | null >( null )
const confirming = ref ( false ), confirmError = ref ( '' )
const canConfirm = computed (() => canCreate . value && workflow . value ? . status === 'WAIT_CUSTOMER_CONFIRM' )
2026-10-10 02:19:39 +08:00
const rows = ref < ProcurementSummary [] >([]), total = ref ( 0 )
const query = reactive ({ page : 1 , size : 20 , orderNo : '' , purchaseCategory : '' , productName : '' , status : '' })
let requestSequence = 0 , active = true , inFlight = ''
2026-10-27 17:39:28 +08:00
let createAttempt : { fingerprint : string ; key : string } | null = null
let confirmAttempt : { fingerprint : string ; key : string } | null = null
2026-10-10 02:19:39 +08:00
const queryModel = computed < QueryModel >({
get : () => ({ orderNo : query . orderNo , purchaseCategory : query . purchaseCategory , productName : query . productName , status : query . status }),
set : value => { for ( const key of [ 'orderNo' , 'purchaseCategory' , 'productName' , 'status' ] as const ) query [ key ] = String ( value [ key ] || '' ) },
})
const queryFields : QueryField [] = [
{ key : 'orderNo' , label : '采购单号' , type : 'input' }, { key : 'purchaseCategory' , label : '采购品类' , type : 'input' },
{ key : 'productName' , label : '商品名称' , type : 'input' },
2026-10-27 17:39:28 +08:00
{ key : 'status' , label : '履约进度' , type : 'select' , options : Object . entries ( procurementStatuses ). map (([ value , label ]) => ({ value , label })) },
2026-10-10 02:19:39 +08:00
]
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 90 , fixed : 'left' , slotName : 'actions' },
2026-10-26 13:43:20 +08:00
{ prop : 'procurementDirection' , label : '采购方向' , minWidth : 170 , slotName : 'direction' },
2026-10-10 02:19:39 +08:00
{ prop : 'orderNo' , label : '采购单号' , minWidth : 250 , slotName : 'orderNo' },
{ prop : 'purchaseCategory' , label : '采购品类' , width : 120 }, { prop : 'productName' , label : '商品名称' , minWidth : 200 },
{ prop : 'productSpecification' , label : '商品规格' , minWidth : 160 }, { prop : 'purchaseQuantity' , label : '采购数量(件)' , width : 135 },
{ prop : 'budgetUnitPrice' , label : '预算单价' , width : 155 , slotName : 'budget' },
{ prop : 'status' , label : '履约进度' , width : 180 , slotName : 'status' }, { prop : 'createdAt' , label : '下单时间' , width : 185 , slotName : 'createdAt' },
]
function rowActions ( row : object ) : TableAction [] { return [{ key : 'detail' , label : '详情' , run : () => openDetail ( row as ProcurementSummary ) }] }
function openDetail ( row : ProcurementSummary ) { if ( row . orderNo ) void router . push ({ name : 'cross-border-procurement-detail' , params : { orderNo : row . orderNo } }) }
function returnToList () { if ( canList . value ) void router . push ({ name : 'cross-border-procurement' }) }
function message ( cause : unknown ) {
if ( cause instanceof ApiError && (( cause . status || 0 ) >= 500 || cause . code . startsWith ( 'B' ))) return '服务暂不可用,请稍后重试'
return cause instanceof Error ? cause . message : '请求失败,请稍后重试'
}
async function loadList () {
const params = { page : query . page , size : query . size , orderNo : query . orderNo || undefined , productName : query . productName || undefined , purchaseCategory : query . purchaseCategory || undefined , status : query . status || undefined }
const key = JSON . stringify ([ identity . value , params ])
if ( inFlight === key ) return
const sequence = ++ requestSequence ; inFlight = key ; loading . value = true ; error . value = ''
try { const result = await listProcurements ( params ); if ( sequence === requestSequence ) { rows . value = result . records ; total . value = result . total } }
catch ( cause ) { if ( sequence === requestSequence ) { rows . value = []; total . value = 0 ; error . value = message ( cause ) } }
finally { if ( sequence === requestSequence ) { loading . value = false ; inFlight = '' } }
}
async function loadDetail () {
if ( ! orderNo . value ) { error . value = '采购单号缺失' ; return }
const key = JSON . stringify ([ identity . value , orderNo . value ])
if ( inFlight === key ) return
2026-10-27 17:39:28 +08:00
const sequence = ++ requestSequence ; inFlight = key ; loading . value = true ; error . value = '' ; detail . value = null ; workflow . value = null
try {
const [ result , currentWorkflow ] = await Promise . all ([ getProcurement ( orderNo . value ), getProcurementWorkflow ( orderNo . value )])
if ( sequence === requestSequence ) { detail . value = result ; workflow . value = currentWorkflow }
}
2026-10-10 02:19:39 +08:00
catch ( cause ) { if ( sequence === requestSequence ) error . value = message ( cause ) }
finally { if ( sequence === requestSequence ) { loading . value = false ; inFlight = '' } }
}
2026-10-27 17:39:28 +08:00
async function confirmReceived () {
if ( ! canConfirm . value || confirming . value || ! workflow . value ) return
const owner = identity . value , targetOrder = orderNo . value , version = workflow . value . version
confirming . value = true ; confirmError . value = ''
try {
await ElMessageBox . confirm ( '确认本单收货数量及照片,确认后平台将安排出库。' , '确认采购结果' , { confirmButtonText : '确认' , cancelButtonText : '取消' , type : 'warning' })
if ( ! active || owner !== identity . value || targetOrder !== orderNo . value ) return
const fingerprint = JSON . stringify ([ owner , targetOrder , version ])
if ( confirmAttempt ? . fingerprint !== fingerprint ) confirmAttempt = { fingerprint , key : procurementRequestKey () }
const result = await confirmProcurement ( targetOrder , { version , idempotencyKey : confirmAttempt . key })
if ( ! active || owner !== identity . value || targetOrder !== orderNo . value ) return
workflow . value = result ; confirmAttempt = null
if ( detail . value ) detail . value = { ... detail . value , order : { ... detail . value . order , status : result . status , version : result . version } }
ElMessage . success ( '采购结果已确认' )
} catch ( cause ) {
if ( cause !== 'cancel' && cause !== 'close' && active && owner === identity . value && targetOrder === orderNo . value ) confirmError . value = message ( cause )
} finally { confirming . value = false }
}
2026-10-10 02:19:39 +08:00
function search () { if ( query . page === 1 ) void loadList (); else query . page = 1 }
function resetQuery () { const reload = query . page === 1 ; Object . assign ( query , { page : 1 , orderNo : '' , purchaseCategory : '' , productName : '' , status : '' }); if ( reload ) void loadList () }
2026-10-27 17:39:28 +08:00
function resetForm () { submitError . value = '' ; submittedOrder . value = '' ; Object . assign ( form , emptyProcurementDraft ()); createAttempt = null }
2026-10-10 02:19:39 +08:00
async function submit () {
2026-10-22 23:06:02 +08:00
if ( submitting . value || ! canCreate . value || ! identity . value || submittedOrder . value ) return
const owner = identity . value ; submitting . value = true ; submitError . value = ''
2026-10-10 02:19:39 +08:00
try {
2026-10-27 17:39:28 +08:00
const payload = buildProcurementCreate ( form , companyName . value , '' )
const fingerprint = JSON . stringify ([ owner , payload ])
if ( createAttempt ? . fingerprint !== fingerprint ) createAttempt = { fingerprint , key : procurementRequestKey () }
const result = await createProcurement ({ ... payload , idempotencyKey : createAttempt . key })
2026-10-10 02:19:39 +08:00
if ( ! active || owner !== identity . value ) return
2026-10-27 17:39:28 +08:00
createAttempt = null ; submittedOrder . value = result . order . orderNo ; ElMessage . success ( '采购订单已提交' )
2026-10-10 02:19:39 +08:00
if ( canList . value ) await router . push ({ name : 'cross-border-procurement-detail' , params : { orderNo : result . order . orderNo } })
2026-10-22 23:06:02 +08:00
} catch ( cause ) { if ( active && owner === identity . value ) submitError . value = message ( cause ) }
finally { submitting . value = false }
2026-10-10 02:19:39 +08:00
}
watch (() => [ mode . value , orderNo . value , query . page , query . size , identity . value ], ( values , previous ) => {
2026-10-27 17:39:28 +08:00
if ( values [ 4 ] !== previous ? .[ 4 ]) { rows . value = []; total . value = 0 ; createAttempt = null }
if ( values [ 4 ] !== previous ? .[ 4 ] || values [ 1 ] !== previous ? .[ 1 ]) confirmAttempt = null
++ requestSequence ; inFlight = '' ; loading . value = false ; error . value = '' ; detail . value = null ; workflow . value = null ; confirmError . value = ''
2026-10-22 23:06:02 +08:00
if ( mode . value === 'create' ) resetForm ()
2026-10-10 02:19:39 +08:00
else if ( mode . value === 'detail' ) void loadDetail ()
else void loadList ()
}, { immediate : true })
onBeforeUnmount (() => { active = false ; ++ requestSequence })
2026-08-03 11:44:50 +08:00
</ script >
2026-10-10 02:19:39 +08:00
< template >
< section class = "procurement-page client-ui" : aria -busy =" loading || submitting " >
< template v-if = "mode === 'list'" >
< QueryToolbar v-model = "queryModel" layout="grid" ariaLabel="采购订单查询条件" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetQuery" />
< div class = "procurement-table-card" >
< DataTable v -model:page = "query.page" v -model:page-size = "query.size" :data = "rows" :columns = "columns" :row-actions = "rowActions" :loading = "loading" :total = "total" : page -sizes ="[ 10 , 20 , 50 , 100 ]" row -key =" orderNo " empty -text =" 暂无采购订单 " >
< template # toolbar -actions >< el-button v-if = "canCreate" type="primary" @click="router.push({ name: 'cross-border-procurement-create' })" > 新增下单 </ el -button ></ template >
< template v-if = "error" #error><div class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadList" > 重新加载 </ el -button ></ div ></ template >
< template # orderNo = "{ row }" >< button class = "procurement-link" type = "button" @click ="openDetail(row as ProcurementSummary)" >{{ ( row as ProcurementSummary ) .orderNo || '—' }}</ button ></ template >
< template # budget = "{ row }" >{{ money (( row as ProcurementSummary ). budgetUnitPrice , ( row as ProcurementSummary ). currency ) }}</ template >
2026-10-26 13:43:20 +08:00
< template # direction = "{ row }" >{{ directionLabel (( row as ProcurementSummary ). procurementDirection ) }}</ template >
< template # status = "{ row }" >< el-tag : type = "statusTone((row as ProcurementSummary).status)" size = "small" >{{ statusLabel (( row as ProcurementSummary ). status , ( row as ProcurementSummary ). procurementDirection ) }}</ el-tag ></ template >
2026-10-10 02:19:39 +08:00
< template # createdAt = "{ row }" >{{ dateTime (( row as ProcurementSummary ). createdAt ) }}</ template >
</ DataTable >
</ div >
</ template >
< section v-else-if = "mode === 'detail'" v-loading="loading" class="procurement-detail" aria-label="采购订单详情" >
2026-10-27 17:39:28 +08:00
< header class = "procurement-heading" >< div >< el-button link type = "primary" @click ="returnToList" > ← 返回列表 </ el -button >< h2 >{{ detail ? . order . orderNo || '采购订单详情' }}</ h2 ></ div >< div >< el-tag v-if = "detail" :type="statusTone(workflow?.status || detail.order.status)">{{ statusLabel(workflow?.status || detail.order.status, detail.order.procurementDirection) }}</el-tag><el-button v-if="canConfirm" type="primary" :loading="confirming" @click="confirmReceived">确认采购结果</el-button><el-button :disabled="loading || confirming" @click="loadDetail" > 刷新 </ el -button ></ div ></ header >
2026-10-10 02:19:39 +08:00
< div v-if = "error" class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadDetail" > 重新加载 </ el -button ></ div >
2026-10-27 17:39:28 +08:00
< template v-else-if = "detail && workflow"><el-alert v-if="confirmError" :title="confirmError" type="error" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
2026-10-10 02:19:39 +08:00
</ section >
< section v-else class = "procurement-create" aria -label =" 采购下单 " >
2026-10-27 17:39:28 +08:00
< header class = "procurement-heading" >< div >< h2 > 采购下单 </ h2 ></ div >< el-button v-if = "canList" @click="returnToList" > 返回列表 </ el -button ></ header >
2026-10-10 02:19:39 +08:00
< el-result v-if = "!canCreate" icon="warning" title="没有采购下单权限" sub-title="请联系企业管理员配置权限" />
2026-10-22 23:06:02 +08:00
< el-result v-else-if = "submittedOrder" icon="success" title="采购订单已提交" :sub-title="submittedOrder"><template #extra><el-button @click="resetForm" > 新建另一单 </ el -button ></ template ></ el-result >
2026-10-10 02:19:39 +08:00
< template v-else >
< el-alert v-if = "submitError" class="submit-error" :title="submitError" type="error" :closable="false" />
2026-10-22 23:06:02 +08:00
< el-form :model = "form" label -position =" top " :disabled = "submitting" @submit.prevent ="submit" >
2026-10-26 13:43:20 +08:00
< el-form-item label = "采购方向" required >< el-select v-model = "form.procurementDirection"><el-option v-for="(label, value) in procurementDirections" :key="value" :label="label" :value="value" /></ el-select ></ el-form-item >
2026-10-10 02:19:39 +08:00
< h3 > 商品与预算 </ h3 >< div class = "procurement-form-grid" >
2026-10-20 14:52:22 +08:00
< el-form-item label = "采购品类" >< el-input v -model .trim =" form.purchaseCategory " maxlength = "128" placeholder = "" /></ el-form-item >
< el-form-item label = "商品名称" required >< el-input v -model .trim =" form.productName " maxlength = "255" placeholder = "" /></ el-form-item >
2026-10-10 02:19:39 +08:00
< el-form-item label = "商品规格" >< el-input v -model .trim =" form.productSpecification " maxlength = "500" placeholder = "规格、型号" /></ el-form-item >
< el-form-item label = "采购数量(件)" required >< el-input v -model .trim =" form.purchaseQuantity " inputmode = "numeric" maxlength = "18" placeholder = "正整数" /></ el-form-item >
2026-10-27 17:39:28 +08:00
< el-form-item label = "预算单价" required >< el-input v -model .trim =" form.budgetUnitPrice " inputmode = "decimal" maxlength = "20" placeholder = "0.00" /></ el-form-item >
2026-10-10 02:19:39 +08:00
< el-form-item label = "预算币种" required >< el-select v-model = "form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></ el-select ></ el-form-item >
< el-form-item class = "full" label = "商品链接" >< el-input v -model .trim =" form.productUrl " maxlength = "2000" placeholder = "https://…" /></ el-form-item >
2026-10-28 16:56:33 +08:00
</ div >
2026-10-26 13:43:20 +08:00
< h3 >{{ regionText ( '最终国内收件资料' , form . procurementDirection ) }}</ h3 >< div class = "procurement-form-grid" >
2026-10-10 02:19:39 +08:00
< el-form-item label = "收件人" required >< el-input v -model .trim =" form.contactName " maxlength = "128" placeholder = "实际收件人姓名" /></ el-form-item >
< el-form-item label = "联系电话" required >< el-input v -model .trim =" form.contactPhone " maxlength = "64" type = "tel" placeholder = "快递可联系的电话" /></ el-form-item >
2026-10-26 13:43:20 +08:00
< el-form-item class = "full" : label = "regionText('完整国内地址', form.procurementDirection)" required >< el-input v -model .trim =" form.receivingAddress " maxlength = "1000" : placeholder = "form.procurementDirection === 'DOMESTIC_TO_OVERSEAS' ? '国家、州/省、市、邮编及详细地址' : '省、市、区县、街道及详细门牌'" /></ el-form-item >
2026-10-27 17:39:28 +08:00
< el-form-item class = "full" label = "备注" >< el-input v-model = "form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="" /></ el-form-item >
2026-10-10 02:19:39 +08:00
</ div >
</ el-form >
2026-10-22 23:06:02 +08:00
< footer class = "procurement-form-actions" >< el-button type = "primary" :loading = "submitting" : disabled = "!companyName || !identity" @click ="submit" > 提交采购订单 </ el -button ></ footer >
2026-10-10 02:19:39 +08:00
</ template >
</ section >
</ section >
</ template >
< style scoped >
. procurement - page { display : grid ; gap : var ( -- client - section - gap ); min - width : 0 ; }
. procurement - table - card , . procurement - detail , . procurement - create { min - width : 0 ; border : 1 px solid var ( -- client - border ); border - radius : var ( -- client - radius ); background : # fff ; }
. procurement - table - card { overflow : hidden ; }. procurement - detail , . procurement - create { padding : 16 px 20 px 24 px ; }
. procurement - heading { display : flex ; justify - content : space - between ; align - items : center ; gap : 20 px ; min - height : 54 px ; border - bottom : 1 px solid var ( -- border ); padding - bottom : 12 px ; }
. procurement - heading > div { display : flex ; align - items : center ; gap : 14 px ; min - width : 0 ; }. procurement - heading h2 { margin : 0 ; color : var ( -- navy ); font - size : 18 px ; overflow - wrap : anywhere ; }
. procurement - create . procurement - heading > div { display : block ; }. procurement - heading p { margin : 7 px 0 0 ; font - size : 13 px ; color : var ( -- muted ); }
. procurement - link { padding : 0 ; border : 0 ; background : transparent ; color : var ( -- client - action ); font : inherit ; cursor : pointer ; text - align : left ; }. procurement - link : hover { text - decoration : underline ; }
. procurement - error { min - height : 160 px ; display : flex ; justify - content : center ; align - items : center ; gap : 16 px ; color : # b42318 ; }. procurement - error span { max - width : 900 px ; overflow - wrap : anywhere ; }
. procurement - form - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 0 24 px ; }. procurement - form - grid . full { grid - column : 1 / - 1 ; }. procurement - form - grid : deep (. el - select ) { width : 100 % ; }
2026-10-28 16:56:33 +08:00
. procurement - create h3 { margin : 22 px 0 16 px ; color : var ( -- navy ); font - size : 14 px ; }
2026-10-10 02:19:39 +08:00
. procurement - form - actions { display : flex ; justify - content : flex - end ; gap : 12 px ; padding - top : 16 px ; border - top : 1 px solid var ( -- border ); }. submit - error { margin : 12 px 0 ; }
@ media ( max - width : 767 px ) {
. procurement - form - grid { grid - template - columns : 1 fr ; }. procurement - heading , . procurement - heading > div { flex - wrap : wrap ; }
}
</ style >