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'
import { ElMessage } from 'element-plus'
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'
import { createProcurement , getProcurement , listProcurements , type ProcurementCreate , type ProcurementDetail , type ProcurementSummary } from '../api/procurement'
import { buildProcurementCreate , dateTime , draftBudgetTotal , emptyProcurementDraft , money , procurementStatuses , statusLabel , statusTone } from '../procurementPresentation'
import { definitelyRejected , procurementRequestKey , restoreProcurementSubmission } from '../procurementSubmission'
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 storageKey = computed (() => identity . value ? `cc-procurement-submit: ${ encodeURIComponent ( identity . value ) } ` : null )
const companyName = computed (() => auth . profile . tenantName || ( auth . user ? . tenantName && ! /^企业租户 \d+$/ . test ( auth . user . tenantName ) ? auth . user . tenantName : '' ))
const form = reactive ( emptyProcurementDraft ())
const pending = shallowRef < ProcurementCreate | null >( null )
const recoveryError = ref ( '' ), submitError = ref ( '' ), submittedOrder = ref ( '' )
const submitting = ref ( false ), loading = ref ( false ), error = ref ( '' )
const detail = shallowRef < ProcurementDetail | null >( null )
const rows = ref < ProcurementSummary [] >([]), total = ref ( 0 )
const query = reactive ({ page : 1 , size : 20 , orderNo : '' , purchaseCategory : '' , productName : '' , status : '' })
let requestSequence = 0 , active = true , inFlight = ''
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' },
{ key : 'status' , label : '履约进度' , type : 'select' , options : Object . entries ( procurementStatuses ). map (([ value , label ]) => ({ value , label })) },
]
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 90 , fixed : 'left' , slotName : 'actions' },
{ 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
const sequence = ++ requestSequence ; inFlight = key ; loading . value = true ; error . value = '' ; detail . value = null
try { const result = await getProcurement ( orderNo . value ); if ( sequence === requestSequence ) detail . value = result }
catch ( cause ) { if ( sequence === requestSequence ) error . value = message ( cause ) }
finally { if ( sequence === requestSequence ) { loading . value = false ; inFlight = '' } }
}
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 () }
function populate ( payload : ProcurementCreate ) {
Object . assign ( form , { purchaseCategory : payload . purchaseCategory , productName : payload . productName , productSpecification : payload . productSpecification || '' , productUrl : payload . productUrl || '' ,
purchaseQuantity : String ( payload . purchaseQuantity ), budgetUnitPrice : String ( payload . budgetUnitPrice ), currency : payload . currency , receivingAddress : payload . receivingAddress ,
contactName : payload . contactName , contactPhone : payload . contactPhone , remark : payload . remark || '' })
}
function restorePending () {
pending . value = null ; recoveryError . value = '' ; submitError . value = '' ; submittedOrder . value = '' ; Object . assign ( form , emptyProcurementDraft ())
if ( ! storageKey . value ) return
try { const saved = sessionStorage . getItem ( storageKey . value ); if ( saved ) { pending . value = Object . freeze ( restoreProcurementSubmission ( saved )); populate ( pending . value ) } }
catch { recoveryError . value = '上次提交记录暂无法恢复,请先在订单列表或通过客服核对提交结果,避免重复下单。' }
}
async function submit () {
if ( submitting . value || ! canCreate . value || recoveryError . value || ! storageKey . value ) return
submitError . value = '' ; const owner = identity . value , key = storageKey . value ; const resuming = Boolean ( pending . value )
let started = false
try {
if ( ! pending . value ) {
const payload = buildProcurementCreate ( form , companyName . value , procurementRequestKey ())
try { sessionStorage . setItem ( key , JSON . stringify ( payload )) } catch { throw new Error ( '浏览器暂无法保留提交信息,请允许会话存储后重试' ) }
pending . value = Object . freeze ( payload )
}
submitting . value = true ; started = true
const result = await createProcurement ( pending . value )
sessionStorage . removeItem ( key )
if ( ! active || owner !== identity . value ) return
pending . value = null ; submittedOrder . value = result . order . orderNo ; ElMessage . success ( '采购订单已提交' )
if ( canList . value ) await router . push ({ name : 'cross-border-procurement-detail' , params : { orderNo : result . order . orderNo } })
} catch ( cause ) {
if ( ! active || owner !== identity . value ) return
if ( ! resuming && cause instanceof ApiError && definitelyRejected ( cause )) { sessionStorage . removeItem ( key ); pending . value = null }
submitError . value = started && pending . value ? ` ${ message ( cause ) } 。提交结果尚未确认,请再次确认上次提交结果。` : message ( cause )
} finally { submitting . value = false }
}
watch (() => [ mode . value , orderNo . value , query . page , query . size , identity . value ], ( values , previous ) => {
if ( values [ 4 ] !== previous ? .[ 4 ]) { rows . value = []; total . value = 0 }
++ requestSequence ; inFlight = '' ; loading . value = false ; error . value = '' ; detail . value = null
if ( mode . value === 'create' ) restorePending ()
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 >
< template # status = "{ row }" >< el-tag : type = "statusTone((row as ProcurementSummary).status)" size = "small" >{{ statusLabel (( row as ProcurementSummary ). status ) }}</ el-tag ></ template >
< 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="采购订单详情" >
< 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(detail.order.status)">{{ statusLabel(detail.order.status) }}</el-tag><el-button :disabled="loading" @click="loadDetail" > 刷新 </ el -button ></ div ></ header >
< div v-if = "error" class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadDetail" > 重新加载 </ el -button ></ div >
< ProcurementDetailPanel v-else-if = "detail" :detail="detail" /><el-skeleton v-else :rows="8" animated />
</ section >
< section v-else class = "procurement-create" aria -label =" 采购下单 " >
< header class = "procurement-heading" >< div >< h2 > 海外采购下单 </ h2 >< p > 一单一个商品及规格 ; 预算仅授权样品货款 , 运费及关税另行核定 。 </ p ></ div >< el-button v-if = "canList" @click="returnToList" > 返回列表 </ el -button ></ header >
< el-result v-if = "!canCreate" icon="warning" title="没有采购下单权限" sub-title="请联系企业管理员配置权限" />
< el-result v-else-if = "submittedOrder" icon="success" title="采购订单已提交" :sub-title="submittedOrder"><template #extra><el-button @click="restorePending" > 新建另一单 </ el -button ></ template ></ el-result >
< template v-else >
< el-alert v-if = "recoveryError" :title="recoveryError" type="error" :closable="false" />
< el-alert v-else-if = "pending" title="已保留本次提交内容。请再次确认上次提交结果,确认前不修改订单信息。" type="warning" :closable="false" />
< el-alert v-if = "submitError" class="submit-error" :title="submitError" type="error" :closable="false" />
< el-form :model = "form" label -position =" top " : disabled = "submitting || Boolean(pending) || Boolean(recoveryError)" @submit.prevent ="submit" >
< h3 > 商品与预算 </ h3 >< div class = "procurement-form-grid" >
2026-10-20 11:54:44 +08:00
< el-form-item label = "采购品类" >< el-input v -model .trim =" form.purchaseCategory " maxlength = "128" placeholder = "请输入采购品类(选填)" /></ el-form-item >
2026-10-10 02:19:39 +08:00
< el-form-item label = "商品名称" required >< el-input v -model .trim =" form.productName " maxlength = "255" placeholder = "请输入商品名称" /></ el-form-item >
< 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 >
< el-form-item label = "预算单价" required >< el-input v -model .trim =" form.budgetUnitPrice " inputmode = "decimal" maxlength = "20" placeholder = "请明确填写;免费样品填 0" /></ el-form-item >
< 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 >
</ div >< p class = "procurement-hint" > 货款预算总上限 : < strong >{{ money ( draftBudgetTotal ( form ), form . currency ) }}</ strong > 。 零预算仅授权免费样品 ; 实际货款按供应商实付核定 。 </ p >
< h3 > 最终国内收件资料 </ h3 >< div class = "procurement-form-grid" >
< 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 >
< el-form-item class = "full" label = "完整国内地址" required >< el-input v -model .trim =" form.receivingAddress " maxlength = "1000" placeholder = "省、市、区县、街道及详细门牌" /></ el-form-item >
< 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 >
</ div >
</ el-form >
2026-10-20 11:54:44 +08:00
< footer class = "procurement-form-actions" >< el-button type = "primary" :loading = "submitting" : disabled = "Boolean(recoveryError) || (!pending && !companyName) || !identity" @click ="submit" >{{ pending ? ' 确认上次提交结果 ' : ' 提交采购订单 ' }} </ 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 % ; }
. procurement - create h3 { margin : 22 px 0 16 px ; color : var ( -- navy ); font - size : 14 px ; }. procurement - hint { margin : 0 0 20 px ; line - height : 1.8 ; font - size : 13 px ; color : var ( -- muted ); }. procurement - hint strong { color : var ( -- navy ); }
. 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 >