2026-07-30 17:59:43 +08:00
< script setup lang = "ts" >
import { computed , reactive , ref , watch } from 'vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import { ExternalLink , FileUp , Pencil , Phone , Plus , RotateCcw , Search , Truck } from '@lucide/vue'
import MetricStrip from '@/components/MetricStrip.vue'
import StaticBatchImportDialog from '@/components/StaticBatchImportDialog.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAdminDataStore } from '@/stores/adminData'
import type { ExpressCompany , ExpressCompanyStatus } from '@/types/admin'
const store = useAdminDataStore ()
const access = useAccessPreviewStore ()
const detailOpen = ref ( false )
const editorOpen = ref ( false )
const importOpen = ref ( false )
const selectedCompany = ref < ExpressCompany | null >( null )
const page = ref ( 1 )
const pageSize = 10
const filters = reactive ({ keyword : '' , status : '' as ExpressCompanyStatus | '' })
const emptyForm = () : Omit < ExpressCompany , ' updatedAt ' > => ({ id : '' , name : '' , code : '' , website : '' , phone : '' , status : '合作中' , remark : '' })
const form = reactive ( emptyForm ())
const editorLabel = computed (() => form . id ? '编辑快递公司' : '新增快递公司' )
const { captureBaseline } = useUnsavedWorkRegistration ({
key : 'express-company-editor' ,
label : editorLabel ,
isOpen : editorOpen ,
model : form ,
requiredPermission : computed (() => form . id ? null : 'express-company:write' ),
})
const filteredRows = computed (() => store . expressCompanies . filter (( company ) => {
const keyword = filters . keyword . trim (). toLowerCase ()
const matchesKeyword = ! keyword || [ company . name , company . code , company . website , company . phone ]
. some (( value ) => value . toLowerCase (). includes ( keyword ))
return matchesKeyword && ( ! filters . status || company . status === filters . status )
}))
const pagedRows = computed (() => filteredRows . value . slice (( page . value - 1 ) * pageSize , page . value * pageSize ))
const metrics = computed (() => [
{ label : '全部快递公司' , value : store . expressCompanies . length , note : '平台快递档案' },
{ label : '合作中' , value : store . activeExpressCompanies , note : '可新增到履约信息' },
{ label : '已终止' , value : store . expressCompanies . length - store . activeExpressCompanies , note : '历史订单继续展示' },
{ label : '官网已登记' , value : store . expressCompanies . filter (( item ) => item . website ). length , note : '公开查询入口' },
])
watch ( filters , () => { page . value = 1 })
function resetFilters () { filters . keyword = '' ; filters . status = '' }
function openDetail ( company : ExpressCompany ) { selectedCompany . value = company ; detailOpen . value = true }
function openCreate () { Object . assign ( form , emptyForm ()); captureBaseline (); editorOpen . value = true }
function openEdit ( company : ExpressCompany ) { Object . assign ( form , company ); captureBaseline (); detailOpen . value = false ; editorOpen . value = true }
function websiteUrl ( company : ExpressCompany ) { return /^https?:\/\//i . test ( company . website ) ? company . website : `https:// ${ company . website } ` }
function saveCompany () {
if ( ! form . name . trim () || ! form . code . trim ()) { ElMessage . warning ( '请填写快递公司名称和稳定编码' ); return }
try {
store . saveExpressCompany ({ ... form , id : form . id || undefined })
editorOpen . value = false
ElMessage . success ( form . id ? '快递公司已更新' : '快递公司已创建' )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '快递公司保存失败' )
}
}
async function toggleStatus ( company : ExpressCompany ) {
const target : ExpressCompanyStatus = company . status === '合作中' ? '已终止' : '合作中'
try {
await ElMessageBox . confirm (
target === '已终止' ? '停用后不能新增到订单履约信息,历史订单仍保留公司名称和编码。' : '启用后可重新用于订单履约信息。' ,
` ${ target === '已终止' ? '停用' : '启用' } 快递公司“ ${ company . name } ”` ,
{ confirmButtonText : target === '已终止' ? '确认停用' : '确认启用' , cancelButtonText : '取消' , type : 'warning' },
)
store . setExpressCompanyStatus ( company . id , target )
ElMessage . success ( target === '已终止' ? '快递公司已停用' : '快递公司已启用' )
} catch {
// 用户取消,不需要反馈。
}
}
</ script >
< template >
<!-- THESIS : 把快递档案升级为可维护 、 可批量校验的履约主数据 , 而不是停留在只读名录 。
OWN - WORLD : 继承 Admin 的白色数据面板 、 海军蓝导航 、 紧凑表格 、 克制状态色和右侧抽屉 。
STORY : 平台人员筛选档案 , 维护唯一编码与名称 , 控制启停 , 并按标准模板执行整批导入 。
FIRST VIEWPORT : 摘要 、 双条件筛选 、 导入与新增动作 、 完整名录连续排列 。
FORM : 现有 Operate 页面结构的深化 , 详情与编辑分层 , 导入使用受保护对话框 。
FINISH : unreviewed and undocumented is unfinished ; this build ends with the finish review , the verdict , and DESIGN . md -->
< div class = "business-page express-company-page" >
< MetricStrip :items = "metrics" />
< section class = "surface-panel filter-panel" aria -label =" 快递公司筛选 " >
< div class = "filter-fields" >
< el-input v-model = "filters.keyword" clearable placeholder="公司名称、编码、官网或电话" aria-label="快递公司关键词"><template #prefix><Search :size="16" /></ template ></ el-input >
< el-select v-model = "filters.status" clearable placeholder="全部状态" aria-label="合作状态"><el-option label="合作中" value="合作中" /><el-option label="已终止" value="已终止" /></ el-select >
</ div >
< div class = "filter-actions" >
< el-button @click ="resetFilters" >< RotateCcw :size = "16" /> 重置 </ el-button >
< el-button v-if = "access.can('express-company:import')" @click="importOpen = true"><FileUp :size="16" /> 批量导入 </ el-button >
< el-button v-if = "access.can('express-company:write')" type="primary" @click="openCreate"><Plus :size="16" /> 新增快递公司 </ el-button >
</ div >
</ section >
< section class = "surface-panel table-panel" >
< div class = "panel-heading" >< div >< h2 > 快递公司名录 </ h2 >< p > 编码和名称必须唯一 ; 停用后不允许新增引用 , 历史订单快照不受影响 。 </ p ></ div ></ div >
< el-table :data = "pagedRows" row -key =" id " empty -text =" 没有符合条件的快递公司 " >
2026-07-30 21:23:39 +08:00
< el-table-column label = "操作" width = "220" fixed = "left" >< template # default = "{ row }" >< div class = "table-actions" >< el-button link type = "primary" @click ="openDetail(row as ExpressCompany)" > 详情 </ el -button >< el-button v-if = "access.can('express-company:write')" link type="primary" @click="openEdit(row as ExpressCompany)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('express-company:write')" link :type="(row as ExpressCompany).status === '合作中' ? 'danger' : 'success'" @click="toggleStatus(row as ExpressCompany)" >{{ ( row as ExpressCompany ) .status === ' 合作中 ' ? ' 停用 ' : ' 启用 ' }} </ el -button ></ div ></ template ></ el-table-column >
2026-07-30 17:59:43 +08:00
< el-table-column label = "公司名称" min -width =" 190 " >< template # default = "{ row }" >< div class = "express-company-cell" >< span >< Truck :size = "18" /></ span >< div >< strong >{{ ( row as ExpressCompany ). name }}</ strong >< small >{{ ( row as ExpressCompany ). id }} · {{ ( row as ExpressCompany ). updatedAt }}</ small ></ div ></ div ></ template ></ el-table-column >
< el-table-column label = "编码" min -width =" 110 " >< template # default = "{ row }" >< code class = "code-value" >{{ ( row as ExpressCompany ). code }}</ code ></ template ></ el-table-column >
< el-table-column label = "官网" min -width =" 220 " >< template # default = "{ row }" >< a v-if = "(row as ExpressCompany).website" class="company-link" :href="websiteUrl(row as ExpressCompany)" target="_blank" rel="noopener noreferrer">{{ (row as ExpressCompany).website }}<ExternalLink :size="14" /></ a >< span v-else > — </ span ></ template > </ el -table-column >
< el-table-column label = "客服电话" min -width =" 150 " >< template # default = "{ row }" >< span class = "phone-value" >< Phone :size = "14" />{{ ( row as ExpressCompany ). phone || '—' }}</ span ></ template ></ el-table-column >
< el-table-column label = "状态" width = "100" >< template # default = "{ row }" >< StatusBadge : status = "(row as ExpressCompany).status" /></ template ></ el-table-column >
</ el-table >
< div class = "table-footer" >< span > 显示 {{ pagedRows . length }} 条 , 共 {{ filteredRows . length }} 条 </ span >< el-pagination v -model:current-page = "page" :page-size = "pageSize" :total = "filteredRows.length" layout = "prev, pager, next" /></ div >
</ section >
< el-drawer v-model = "editorOpen" :title="form.id ? '编辑快递公司' : '新增快递公司'" size="620px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false" >
< el-form label -position =" top " class = "drawer-form" >< div class = "form-section" >< h3 > 档案信息 </ h3 >< div class = "form-grid two-cols" >
< el-form-item label = "公司名称" required >< el-input v-model = "form.name" placeholder="请输入快递公司名称" maxlength="80" /></ el-form-item >
< el-form-item label = "稳定编码" required >< el-input v-model = "form.code" placeholder="如 SF" maxlength="30" /></ el-form-item >
< el-form-item label = "官方查询网址" >< el-input v-model = "form.website" placeholder="如 www.sf-express.com" maxlength="160" /></ el-form-item >
< el-form-item label = "客服电话" >< el-input v-model = "form.phone" maxlength="40" /></ el-form-item >
< el-form-item label = "状态" >< el-radio-group v-model = "form.status"><el-radio-button label="合作中" value="合作中" /><el-radio-button label="已终止" value="已终止" /></ el-radio-group ></ el-form-item >
</ div >< el-form-item label = "内部备注" >< el-input v-model = "form.remark" type="textarea" :rows="3" maxlength="300" show -word-limit /></ el-form-item ></ div ></ el-form >
< template # footer >< div class = "drawer-footer" >< el-button @click ="editorOpen = false" > 取消 </ el -button >< el-button v-if = "access.can('express-company:write')" type="primary" @click="saveCompany" > 保存快递公司 </ el -button ></ div ></ template >
</ el-drawer >
< el-drawer v-model = "detailOpen" title="快递公司详情" size="520px" class="business-drawer" :close-on-click-modal="false" >
< template v-if = "selectedCompany"><div class="detail-identity"><span><Truck :size="20" /></span><div><h2>{{ selectedCompany.name }}</h2><p>{{ selectedCompany.code }} · {{ selectedCompany.id }}</p></div><StatusBadge :status="selectedCompany.status" /></div><section class="detail-section"><h3>公开联系信息</h3><dl class="company-detail-list"><div><dt>公司官网</dt><dd><a v-if="selectedCompany.website" class="company-link" :href="websiteUrl(selectedCompany)" target="_blank" rel="noopener noreferrer">{{ selectedCompany.website }}<ExternalLink :size="14" /></a><span v-else>—</span></dd></div><div><dt>客服电话</dt><dd>{{ selectedCompany.phone || '—' }}</dd></div><div><dt>更新时间</dt><dd>{{ selectedCompany.updatedAt }}</dd></div></dl><p class="detail-note" >{{ selectedCompany.remark || ' 暂无内部备注 。' }}</ p ></ section ></ template >
< template # footer >< div class = "drawer-footer" >< el-button @click ="detailOpen = false" > 关闭 </ el -button >< el-button v-if = "access.can('express-company:write') && selectedCompany" type="primary" @click="openEdit(selectedCompany)" > 编辑资料 </ el -button ></ div ></ template >
</ el-drawer >
< StaticBatchImportDialog v-model = "importOpen" entity-label="快递公司" template-url="/templates/express-companies-import-template.xlsx" template-file-name="快递公司导入标准模板-v1.xlsx" />
</ div >
</ template >
< style scoped >
. 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 ; 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 , . express - company - cell small { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. express - company - cell small { color : var ( -- cc - muted ); font - size : 11 px ; }
. company - link { display : inline - flex ; max - width : 100 % ; align - items : center ; gap : 5 px ; color : var ( -- cc - primary ); text - decoration : none ; overflow - wrap : anywhere ; }
. company - link : hover { text - decoration : underline ; }
. company - link : focus - visible { outline : 3 px solid rgba ( 21 , 94 , 239 , .2 ); outline - offset : 2 px ; }
. phone - value { display : inline - flex ; align - items : center ; gap : 6 px ; color : # 344054 ; }
. phone - value svg { color : var ( -- cc - muted ); }
. company - detail - list { margin : 0 ; }
. company - detail - list > div { display : grid ; grid - template - columns : 92 px minmax ( 0 , 1 fr ); gap : 16 px ; padding : 14 px 0 ; border - bottom : 1 px solid var ( -- cc - border ); }
. company - detail - list dt { color : var ( -- cc - muted ); font - size : 12 px ; }
. company - detail - list dd { min - width : 0 ; margin : 0 ; color : # 172033 ; font - size : 13 px ; font - weight : 650 ; }
@ media ( max - width : 420 px ) { . company - detail - list > div { grid - template - columns : 1 fr ; gap : 6 px ; } }
</ style >