2026-08-01 12:17:33 +08:00
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
import { ExternalLink , Pencil , Plus , Power , Truck } from '@lucide/vue'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/components/common/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { getApiErrorDetails , type ApiErrorDetails } from '@/api/http'
import {
changePlatformExpressCompanyStatus ,
createPlatformExpressCompany ,
listPlatformExpressCompanies ,
updatePlatformExpressCompany ,
type PlatformExpressCompanyResource ,
type PlatformExpressCompanyStatus ,
type SavePlatformExpressCompanyRequest ,
} from '@/api/platformExpressCompanies'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const fixedTenantId = 10001
const access = useAccessPreviewStore ()
const loading = ref ( false )
const saving = ref ( false )
const editorOpen = ref ( false )
const formRef = ref < FormInstance >()
const rows = ref < PlatformExpressCompanyResource [] >([])
const page = ref ( 1 )
const pageSize = ref ( 20 )
const total = ref ( 0 )
const loadError = ref < ApiErrorDetails | null >( null )
const statusLoadingIds = ref ( new Set < string >())
type ExpressCompanyFilters = QueryModel & {
keyword : string
status : PlatformExpressCompanyStatus | ''
}
const filters = ref < ExpressCompanyFilters >({ keyword : '' , status : '' })
const appliedFilters = ref < ExpressCompanyFilters >({ ... filters . value })
const queryFields : QueryField [] = [
{ key : 'keyword' , type : 'input' , placeholder : '快递公司名称或编码' , props : { maxlength : 128 , 'aria-label' : '快递公司关键词' } },
{
key : 'status' ,
type : 'select' ,
placeholder : '全部状态' ,
props : { 'aria-label' : '快递公司状态' },
options : [
{ label : '启用' , value : 'ACTIVE' },
{ label : '停用' , value : 'INACTIVE' },
],
},
]
let loadSequence = 0
const emptyForm = () : SavePlatformExpressCompanyRequest & { id : string } => ({
id : '' ,
companyCode : '' ,
companyName : '' ,
servicePhone : null ,
website : null ,
trackingUrlTemplate : null ,
status : 'ACTIVE' ,
remark : null ,
})
const form = reactive ( emptyForm ())
const rules : FormRules < typeof form > = {
companyCode : [{ required : true , message : '请输入快递公司编码' , trigger : 'blur' }],
companyName : [{ required : true , message : '请输入快递公司名称' , trigger : 'blur' }],
website : [{ type : 'url' , message : '请输入以 http:// 或 https:// 开头的完整网址' , trigger : 'blur' }],
status : [{ required : true , message : '请选择状态' , trigger : 'change' }],
}
const editorTitle = computed (() => form . id ? '编辑快递公司' : '新增快递公司' )
const editorPermission = computed (() => form . id ? 'platform:express:edit' : 'platform:express:create' )
const { captureBaseline } = useUnsavedWorkRegistration ({
key : 'express-company-editor' ,
label : editorTitle ,
isOpen : editorOpen ,
model : form ,
requiredPermission : editorPermission ,
})
watch (
() => access . can ( editorPermission . value ),
( canWrite ) => {
if ( canWrite || ! editorOpen . value ) return
editorOpen . value = false
Object . assign ( form , emptyForm ())
},
{ flush : 'sync' },
)
function optionalText ( value : string | null ) {
const trimmed = value ? . trim ()
return trimmed || null
}
function safeHttpUrl ( value : string | null ) {
if ( ! value ) return null
try {
const url = new URL ( value )
return [ 'http:' , 'https:' ]. includes ( url . protocol ) ? url . toString () : null
} catch {
return null
}
}
function formatUpdatedAt ( value ?: string | null ) {
return value ? . replace ( 'T' , ' ' ). slice ( 0 , 16 ) || '—'
}
const expressCompanyColumns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 170 , fixed : 'left' , slotName : 'actions' },
{ key : 'company' , label : '快递公司' , minWidth : 210 , slotName : 'company' },
{ prop : 'servicePhone' , label : '客服电话' , minWidth : 130 , formatter : ( row : PlatformExpressCompanyResource ) => row . servicePhone || '—' },
{ key : 'website' , label : '官方网站' , minWidth : 210 , slotName : 'website' },
{ key : 'trackingUrlTemplate' , label : '轨迹查询模板' , minWidth : 260 , slotName : 'trackingUrlTemplate' },
{ key : 'remark' , label : '备注' , minWidth : 200 , slotName : 'remark' },
{ prop : 'updatedAt' , label : '更新时间' , minWidth : 150 , formatter : ( row : PlatformExpressCompanyResource ) => formatUpdatedAt ( row . updatedAt ) },
{ key : 'status' , label : '状态' , width : 100 , slotName : 'status' },
]
async function loadExpressCompanies () {
const sequence = ++ loadSequence
loading . value = true
loadError . value = null
try {
const result = await listPlatformExpressCompanies (
fixedTenantId ,
page . value ,
pageSize . value ,
appliedFilters . value . keyword ,
appliedFilters . value . status ,
)
if ( sequence !== loadSequence ) return
rows . value = result . records
total . value = result . total
} catch ( error ) {
if ( sequence !== loadSequence ) return
rows . value = []
total . value = 0
loadError . value = getApiErrorDetails ( error , '快递公司加载失败,请稍后重试' )
} finally {
if ( sequence === loadSequence ) loading . value = false
}
}
function applyFilters ( model : QueryModel ) {
appliedFilters . value = {
keyword : String ( model . keyword || '' ),
status : ( model . status || '' ) as PlatformExpressCompanyStatus | '' ,
}
if ( page . value === 1 ) {
void loadExpressCompanies ()
return
}
page . value = 1
}
function openCreate () {
Object . assign ( form , emptyForm ())
captureBaseline ()
editorOpen . value = true
void formRef . value ? . clearValidate ()
}
function openEdit ( row : PlatformExpressCompanyResource ) {
Object . assign ( form , {
id : String ( row . id ),
companyCode : row . companyCode ,
companyName : row . companyName ,
servicePhone : row . servicePhone ,
website : row . website ,
trackingUrlTemplate : row . trackingUrlTemplate ,
status : row . status ,
remark : row . remark ,
})
captureBaseline ()
editorOpen . value = true
void formRef . value ? . clearValidate ()
}
function buildPayload () : SavePlatformExpressCompanyRequest {
return {
companyCode : form . companyCode . trim (),
companyName : form . companyName . trim (),
servicePhone : optionalText ( form . servicePhone ),
website : optionalText ( form . website ),
trackingUrlTemplate : optionalText ( form . trackingUrlTemplate ),
status : form . status ,
remark : optionalText ( form . remark ),
}
}
async function saveExpressCompany () {
const valid = await formRef . value ? . validate (). catch (() => false )
if ( ! valid ) return
saving . value = true
try {
const payload = buildPayload ()
if ( form . id ) await updatePlatformExpressCompany ( fixedTenantId , form . id , payload )
else await createPlatformExpressCompany ( fixedTenantId , payload )
editorOpen . value = false
ElMessage . success ( form . id ? '快递公司资料已更新' : '快递公司已创建' )
await loadExpressCompanies ()
} catch ( error ) {
const details = getApiErrorDetails ( error , '快递公司保存失败,请检查后重试' )
ElMessage . error ( details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message )
} finally {
saving . value = false
}
}
async function toggleStatus ( row : PlatformExpressCompanyResource ) {
const nextStatus : PlatformExpressCompanyStatus = row . status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox . confirm (
`确定 ${ action } “ ${ row . companyName } ”吗? ${ nextStatus === 'INACTIVE' ? '停用后不会再作为可用快递公司。' : '' } ` ,
` ${ action } 快递公司` ,
{ type : nextStatus === 'ACTIVE' ? 'success' : 'warning' , confirmButtonText : `确认 ${ action } ` , cancelButtonText : '取消' },
)
} catch {
return
}
const id = String ( row . id )
const pending = new Set ( statusLoadingIds . value )
pending . add ( id )
statusLoadingIds . value = pending
try {
await changePlatformExpressCompanyStatus ( fixedTenantId , row . id , nextStatus )
ElMessage . success ( `已 ${ action }${ row . companyName } ` )
await loadExpressCompanies ()
} catch ( error ) {
const details = getApiErrorDetails ( error , ` ${ action } 快递公司失败` )
ElMessage . error ( details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message )
} finally {
const next = new Set ( statusLoadingIds . value )
next . delete ( id )
statusLoadingIds . value = next
}
}
watch ([ page , pageSize ], () => { void loadExpressCompanies () })
onMounted (() => {
void loadExpressCompanies ()
})
</ script >
< template >
< div class = "business-page express-company-page" >
< el-alert v-if = "loadError" type="error" :closable="false" show-icon class="state-alert" >
< template # title > 快递公司加载失败 </ template >
< p >{{ loadError . message }}</ p >
< ApiErrorMeta :details = "loadError" />
< el-button size = "small" @click ="loadExpressCompanies" > 重新查询 </ el -button >
</ el-alert >
2026-08-03 11:45:45 +08:00
< QueryToolbar v-model = "filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" @search="applyFilters" />
2026-08-01 12:17:33 +08:00
< section class = "surface-panel table-panel" >
2026-08-03 11:45:45 +08:00
< div class = "panel-heading" >
< div >
< h2 > 快递公司列表 </ h2 >
< p > 共 {{ total }} 家快递公司 </ p >
</ div >
< el-button v-if = "access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" /> 新增快递公司 </ el-button >
</ div >
2026-08-01 12:17:33 +08:00
< DataTable
v -model:page = "page"
v -model:page-size = "pageSize"
:data = "rows"
:columns = "expressCompanyColumns"
:loading = "loading"
:total = "total"
: page -sizes ="[ 20 , 50 , 100 ]"
aria -label =" 快递公司列表 "
row -key =" id "
: empty -text =" appliedFilters.keyword.trim () || appliedFilters.status ? ' 暂无符合筛选条件的快递公司 ' : ' 暂无快递公司档案 '"
>
< template # actions = "{ row }" >
< div v-if = "access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions" >
< el-button v-if = "access.can('platform:express:edit')" link type="primary" @click="openEdit(row as PlatformExpressCompanyResource)"><Pencil :size="15" /> 编辑 </ el-button >
< el-button
v-if = "access.can('platform:express:status')"
link
: type = "(row as PlatformExpressCompanyResource).status === 'ACTIVE' ? 'danger' : 'success'"
: loading = "statusLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
@click ="toggleStatus(row as PlatformExpressCompanyResource)"
>< Power :size = "15" />{{ ( row as PlatformExpressCompanyResource ). status === 'ACTIVE' ? '停用' : '启用' }}</ el-button >
</ div >
< span v-else class = "muted-value" > 仅查看 </ span >
</ template >
< template # company = "{ row }" >< div class = "express-company-cell" >< span >< Truck :size = "17" /></ span >< div >< strong >{{ ( row as PlatformExpressCompanyResource ). companyName }}</ strong >< small >{{ ( row as PlatformExpressCompanyResource ). companyCode }}</ small ></ div ></ div ></ template >
< template # website = "{ row }" >
< a v-if = "safeHttpUrl((row as PlatformExpressCompanyResource).website)" class="external-link" :href="safeHttpUrl((row as PlatformExpressCompanyResource).website)!" target="_blank" rel="noopener noreferrer">访问官网<ExternalLink :size="13" /></ a >
< span v-else class = "muted-value" >{{ ( row as PlatformExpressCompanyResource ). website || '—' }}</ span >
</ template >
< template # trackingUrlTemplate = "{ row }" >< code class = "tracking-template" >{{ ( row as PlatformExpressCompanyResource ). trackingUrlTemplate || '—' }}</ code ></ template >
< template # remark = "{ row }" >< span class = "remark-cell" >{{ ( row as PlatformExpressCompanyResource ). remark || '—' }}</ span ></ template >
< template # status = "{ row }" >< el-tag : type = "(row as PlatformExpressCompanyResource).status === 'ACTIVE' ? 'success' : 'info'" effect = "light" >{{ ( row as PlatformExpressCompanyResource ). status === 'ACTIVE' ? '启用' : '停用' }}</ el-tag ></ template >
< template # empty >
< el-empty v-if = "!loading" :description="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'" >
< el-button v-if = "!appliedFilters.keyword.trim() && !appliedFilters.status && access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" /> 新增第一家快递公司 </ el-button >
</ el-empty >
</ template >
< template # pagination -left =" { total : tableTotal } " >< span > 共 {{ tableTotal }} 家快递公司 </ span ></ template >
</ DataTable >
</ section >
< el-drawer v-model = "editorOpen" :title="editorTitle" size="580px" class="business-drawer editor-drawer" :close-on-click-modal="false" >
< el-form ref = "formRef" :model = "form" :rules = "rules" label -position =" top " class = "drawer-form" :disabled = "saving" >
< div class = "form-section" >
< h3 > 公司标识 </ h3 >
< div class = "form-grid two-cols" >
< el-form-item label = "快递公司编码" prop = "companyCode" required >< el-input v -model .trim =" form.companyCode " maxlength = "50" placeholder = "例如 SF" /></ el-form-item >
< el-form-item label = "快递公司名称" prop = "companyName" required >< el-input v -model .trim =" form.companyName " maxlength = "128" placeholder = "例如 顺丰速运" /></ el-form-item >
</ div >
< el-form-item label = "状态" prop = "status" required >< el-radio-group v-model = "form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE" > 停用 </ el -radio-button ></ el-radio-group ></ el-form-item >
</ div >
< div class = "form-section" >
< h3 > 服务与查询 </ h3 >
< div class = "form-grid two-cols" >
< el-form-item label = "客服电话" >< el-input v -model .trim =" form.servicePhone " maxlength = "32" placeholder = "例如 95338" /></ el-form-item >
< el-form-item label = "官方网站" prop = "website" >< el-input v -model .trim =" form.website " maxlength = "255" placeholder = "https://www.example.com" /></ el-form-item >
</ div >
< el-form-item label = "轨迹查询地址模板" >< el-input v -model .trim =" form.trackingUrlTemplate " maxlength = "500" placeholder = "https://example.com/track/{trackingNo}" /></ el-form-item >
< p class = "form-help" > 可使用 < code >{ trackingNo }</ code > 作为快递单号占位符 ; 一期不包含轨迹订阅和自动回传 。 </ p >
</ div >
< div class = "form-section" >< h3 > 备注 </ h3 >< el-form-item label = "内部说明" >< el-input v -model .trim =" form.remark " type = "textarea" :rows = "4" maxlength = "500" show -word-limit placeholder = "请输入适用范围、服务限制或其他说明" /></ el-form-item ></ div >
</ el-form >
< template # footer >< div class = "drawer-footer" >< el-button :disabled = "saving" @click ="editorOpen = false" > 取消 </ el -button >< el-button type = "primary" :loading = "saving" @click ="saveExpressCompany" > 保存快递公司 </ el -button ></ div ></ template >
</ el-drawer >
</ div >
</ template >
< style scoped >
. state - alert p { margin : 6 px 0 0 ; line - height : 1.55 ; }
. state - alert . el - button { margin - top : 10 px ; }
. express - company - cell { display : flex ; min - width : 0 ; align - items : center ; gap : 10 px ; }
. express - company - cell > span { display : grid ; width : 34 px ; height : 34 px ; flex : 0 0 34 px ; place - items : center ; border - radius : 7 px ; background : # eef3fa ; color : var ( -- cc - primary ); }
. express - company - cell > div { display : flex ; min - width : 0 ; flex - direction : column ; gap : 3 px ; }
. express - company - cell strong { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. express - company - cell small { color : var ( -- cc - muted ); font - size : 11 px ; }
. external - link { display : inline - flex ; align - items : center ; gap : 5 px ; color : var ( -- cc - primary ); font - size : 12 px ; text - decoration : none ; }
. external - link : hover { text - decoration : underline ; }
. tracking - template { display : block ; max - width : 100 % ; color : # 475467 ; font - family : "Cascadia Code" , Consolas , monospace ; font - size : 11 px ; overflow - wrap : anywhere ; }
. remark - cell { display : - webkit - box ; overflow : hidden ; line - height : 1.5 ; - webkit - box - orient : vertical ; - webkit - line - clamp : 2 ; }
. form - help code { color : # 475467 ; font - family : "Cascadia Code" , Consolas , monospace ; }
2026-08-03 11:45:45 +08:00
@ media ( max - width : 720 px ) {
. panel - heading . el - button { width : 100 % ; }
}
2026-08-01 12:17:33 +08:00
</ style >