2026-08-01 12:17:33 +08:00
< script setup lang = "ts" >
2026-08-04 21:01:39 +08:00
import { computed , onMounted , reactive , ref , watch } from 'vue'
2026-08-03 14:58:17 +08:00
import { ElMessage , ElMessageBox , type FormInstance , type FormRules , type UploadFile } from 'element-plus'
import { Building2 , Download , Eye , MapPin , Pencil , Plus , Power , Upload } from '@lucide/vue'
2026-08-03 18:49:18 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
2026-08-01 12:17:33 +08:00
import {
changePlatformPartnerStatus ,
createPlatformPartner ,
2026-08-03 14:58:17 +08:00
downloadPlatformPartnerImportTemplate ,
getPlatformPartner ,
importPlatformPartners ,
2026-08-01 12:17:33 +08:00
listPlatformPartners ,
updatePlatformPartner ,
2026-08-03 14:58:17 +08:00
type PlatformPartnerImportResult ,
2026-08-01 12:17:33 +08:00
type PlatformPartnerResource ,
type PlatformPartnerStatus ,
2026-08-03 14:58:17 +08:00
type PlatformPartnerLegacyType ,
2026-08-01 12:17:33 +08:00
type PlatformPartnerType ,
type SavePlatformPartnerRequest ,
2026-08-03 18:49:18 +08:00
} from '@/modules/master-data/counterparties/api/platformPartners'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-01 12:17:33 +08:00
const pageSize = ref ( 20 )
const access = useAccessPreviewStore ()
const loading = ref ( false )
const saving = ref ( false )
const editorOpen = ref ( false )
2026-08-03 14:58:17 +08:00
const detailOpen = ref ( false )
const detailLoading = ref ( false )
const detail = ref < PlatformPartnerResource | null >( null )
const importOpen = ref ( false )
const importFile = ref < File | null >( null )
const importing = ref ( false )
const downloadingTemplate = ref ( false )
const importResult = ref < PlatformPartnerImportResult | null >( null )
2026-08-01 12:17:33 +08:00
const formRef = ref < FormInstance >()
const rows = ref < PlatformPartnerResource [] >([])
const page = ref ( 1 )
const total = ref ( 0 )
const loadError = ref < ApiErrorDetails | null >( null )
const statusLoadingIds = ref ( new Set < string >())
2026-08-04 09:05:19 +08:00
let loadSequence = 0
2026-08-01 12:17:33 +08:00
type PartnerFilters = QueryModel & {
keyword : string
type : PlatformPartnerType | ''
status : PlatformPartnerStatus | ''
}
const filters = ref < PartnerFilters >({
keyword : '' ,
type : '' ,
status : '' ,
})
const appliedFilters = ref < PartnerFilters >({ ... filters . value })
2026-08-04 21:01:39 +08:00
const queryFields = computed < QueryField [] >(() => [
2026-08-01 12:17:33 +08:00
{ key : 'keyword' , type : 'input' , placeholder : '单位名称、编码、联系人或电话' },
{
key : 'type' ,
type : 'select' ,
placeholder : '全部类型' ,
options : [
{ label : '客户' , value : 'CUSTOMER' },
{ label : '供应商' , value : 'SUPPLIER' },
2026-08-03 14:58:17 +08:00
{ label : '承运商' , value : 'CARRIER' },
{ label : '报关行' , value : 'CUSTOMS_BROKER' },
{ label : '发货单位' , value : 'SHIPPER' },
{ label : '收货单位' , value : 'CONSIGNEE' },
2026-08-01 12:17:33 +08:00
],
},
{
key : 'status' ,
type : 'select' ,
placeholder : '全部状态' ,
options : [
{ label : '启用' , value : 'ACTIVE' },
{ label : '停用' , value : 'INACTIVE' },
],
},
2026-08-04 21:01:39 +08:00
])
2026-08-01 12:17:33 +08:00
interface PartnerForm extends SavePlatformPartnerRequest {
id : string
}
const emptyForm = () : PartnerForm => ({
id : '' ,
partnerCode : '' ,
partnerName : '' ,
partnerType : 'CUSTOMER' ,
2026-08-03 14:58:17 +08:00
types : [ 'CUSTOMER' ],
2026-08-01 12:17:33 +08:00
contactName : '' ,
contactPhone : '' ,
contactEmail : null ,
countryCode : 'CN' ,
province : null ,
city : '' ,
district : null ,
addressLine : '' ,
postalCode : null ,
status : 'ACTIVE' ,
2026-08-03 14:58:17 +08:00
remark : null ,
2026-08-01 12:17:33 +08:00
})
const form = reactive < PartnerForm >( emptyForm ())
const editorLabel = computed (() => form . id ? '编辑往来单位' : '新增往来单位' )
const editorPermission = computed (() => form . id ? 'platform:counterparty:edit' : 'platform:counterparty:create' )
const { captureBaseline } = useUnsavedWorkRegistration ({
key : 'partner-editor' ,
label : editorLabel ,
isOpen : editorOpen ,
model : form ,
requiredPermission : editorPermission ,
})
const rules : FormRules < PartnerForm > = {
partnerCode : [{ required : true , whitespace : true , message : '请输入往来单位编码' , trigger : 'blur' }],
partnerName : [{ required : true , whitespace : true , message : '请输入往来单位名称' , trigger : 'blur' }],
2026-08-03 14:58:17 +08:00
types : [{ required : true , type : 'array' , min : 1 , message : '请至少选择一个单位类型' , trigger : 'change' }],
2026-08-01 12:17:33 +08:00
contactName : [{ required : true , whitespace : true , message : '请输入联系人' , trigger : 'blur' }],
contactPhone : [{ required : true , whitespace : true , message : '请输入联系电话' , trigger : 'blur' }],
contactEmail : [{ type : 'email' , message : '请输入有效的邮箱地址' , trigger : 'blur' }],
countryCode : [
{ required : true , whitespace : true , message : '请输入国家二字码' , trigger : 'blur' },
{ pattern : /^[A-Z]{2}$/ , message : '国家码须为两个大写英文字母' , trigger : 'blur' },
],
city : [{ required : true , whitespace : true , message : '请输入城市' , trigger : 'blur' }],
addressLine : [{ required : true , whitespace : true , message : '请输入详细地址' , trigger : 'blur' }],
status : [{ required : true , message : '请选择状态' , trigger : 'change' }],
}
const typeLabels : Record < PlatformPartnerType , string > = {
CUSTOMER : '客户' ,
SUPPLIER : '供应商' ,
2026-08-03 14:58:17 +08:00
CARRIER : '承运商' ,
CUSTOMS_BROKER : '报关行' ,
SHIPPER : '发货单位' ,
CONSIGNEE : '收货单位' ,
}
const typeOptions = Object . entries ( typeLabels ). map (([ value , label ]) => ({ value : value as PlatformPartnerType , label }))
function partnerTypeLabel ( type : PlatformPartnerLegacyType ) {
return type === 'BOTH' ? '客户 / 供应商' : typeLabels [ type ]
2026-08-01 12:17:33 +08:00
}
const partnerColumns : DataTableColumn [] = [
2026-08-03 14:58:17 +08:00
{ key : 'actions' , label : '操作' , width : 210 , fixed : 'left' , slotName : 'actions' },
2026-08-01 12:17:33 +08:00
{ key : 'partner' , label : '往来单位' , minWidth : 210 , slotName : 'partner' },
{ key : 'type' , label : '类型' , minWidth : 115 , slotName : 'type' },
{ key : 'contact' , label : '联系人' , minWidth : 165 , slotName : 'contact' },
{ key : 'address' , label : '地址' , minWidth : 240 , slotName : 'address' },
{ key : 'status' , label : '状态' , width : 90 , slotName : 'status' },
{ prop : 'updatedAt' , label : '更新时间' , minWidth : 140 , formatter : ( row : PlatformPartnerResource ) => formatUpdatedAt ( row . updatedAt ) },
]
watch (
() => access . can ( editorPermission . value ),
( canWrite ) => {
if ( canWrite || ! editorOpen . value ) return
editorOpen . value = false
Object . assign ( form , emptyForm ())
},
{ flush : 'sync' },
)
function queryParams () {
return {
page : page . value ,
size : pageSize . value ,
keyword : appliedFilters . value . keyword . trim () || undefined ,
type : appliedFilters . value . type || undefined ,
status : appliedFilters . value . status || undefined ,
}
}
2026-08-04 21:01:39 +08:00
async function loadPartners () {
2026-08-04 09:05:19 +08:00
const sequence = ++ loadSequence
2026-08-01 12:17:33 +08:00
loading . value = true
loadError . value = null
try {
2026-08-06 14:55:17 +08:00
const result = await listPlatformPartners ( queryParams ())
2026-08-04 09:05:19 +08:00
if ( sequence !== loadSequence ) return
2026-08-01 12:17:33 +08:00
rows . value = result . records
total . value = result . total
} catch ( error ) {
2026-08-04 09:05:19 +08:00
if ( sequence !== loadSequence ) return
rows . value = []
total . value = 0
2026-08-01 12:17:33 +08:00
loadError . value = getApiErrorDetails ( error , '往来单位加载失败,请稍后重试' )
} finally {
2026-08-04 09:05:19 +08:00
if ( sequence === loadSequence ) loading . value = false
2026-08-01 12:17:33 +08:00
}
}
function searchPartners ( model : QueryModel ) {
appliedFilters . value = {
keyword : String ( model . keyword || '' ),
type : ( model . type || '' ) as PlatformPartnerType | '' ,
status : ( model . status || '' ) as PlatformPartnerStatus | '' ,
}
if ( page . value === 1 ) {
void loadPartners ()
return
}
page . value = 1
}
function openCreate () {
Object . assign ( form , emptyForm ())
captureBaseline ()
editorOpen . value = true
requestAnimationFrame (() => formRef . value ? . clearValidate ())
}
function nullable ( value : string | null ) {
return value ? . trim () || null
}
function openEdit ( row : PlatformPartnerResource ) {
Object . assign ( form , {
id : String ( row . id ),
partnerCode : row . partnerCode ,
partnerName : row . partnerName ,
2026-08-03 14:58:17 +08:00
partnerType : row . types [ 0 ],
types : [... row . types ],
2026-08-01 12:17:33 +08:00
contactName : row . contactName ,
contactPhone : row . contactPhone ,
contactEmail : row . contactEmail ,
countryCode : row . countryCode ,
province : row . province ,
city : row . city ,
district : row . district ,
addressLine : row . addressLine ,
postalCode : row . postalCode ,
status : row . status ,
2026-08-03 14:58:17 +08:00
remark : row . remark ,
2026-08-01 12:17:33 +08:00
})
captureBaseline ()
editorOpen . value = true
requestAnimationFrame (() => formRef . value ? . clearValidate ())
}
function savePayload () : SavePlatformPartnerRequest {
2026-08-03 14:58:17 +08:00
const types = [... form . types ]
2026-08-01 12:17:33 +08:00
return {
partnerCode : form . partnerCode . trim (),
partnerName : form . partnerName . trim (),
2026-08-03 14:58:17 +08:00
partnerType : types [ 0 ],
types ,
2026-08-01 12:17:33 +08:00
contactName : form . contactName . trim (),
contactPhone : form . contactPhone . trim (),
contactEmail : nullable ( form . contactEmail ),
countryCode : form . countryCode . trim (). toUpperCase (),
province : nullable ( form . province ),
city : form . city . trim (),
district : nullable ( form . district ),
addressLine : form . addressLine . trim (),
postalCode : nullable ( form . postalCode ),
status : form . status ,
2026-08-03 14:58:17 +08:00
remark : nullable ( form . remark ),
2026-08-01 12:17:33 +08:00
}
}
2026-08-03 14:58:17 +08:00
async function openDetail ( row : PlatformPartnerResource ) {
detailOpen . value = true
detailLoading . value = true
detail . value = null
try {
2026-08-06 14:55:17 +08:00
detail . value = await getPlatformPartner ( row . id )
2026-08-03 14:58:17 +08:00
} catch ( error ) {
const meta = getApiErrorDetails ( error , '往来单位详情加载失败' )
ElMessage . error ( meta . requestId ? ` ${ meta . message } ( requestId: ${ meta . requestId } ) ` : meta . message )
detailOpen . value = false
} finally {
detailLoading . value = false
}
}
function openImport () {
importFile . value = null
importResult . value = null
importOpen . value = true
}
function selectImportFile ( uploadFile : UploadFile ) {
importFile . value = uploadFile . raw ?? null
importResult . value = null
}
function removeImportFile () {
importFile . value = null
}
async function downloadTemplate () {
downloadingTemplate . value = true
try {
const blob = await downloadPlatformPartnerImportTemplate ()
const url = URL . createObjectURL ( blob )
const link = document . createElement ( 'a' )
link . href = url
link . download = '往来单位导入模板-v1.xlsx'
link . click ()
URL . revokeObjectURL ( url )
} catch ( error ) {
ElMessage . error ( getApiErrorDetails ( error , '模板下载失败,请重试' ). message )
} finally {
downloadingTemplate . value = false
}
}
async function submitImport () {
if ( ! importFile . value ) {
ElMessage . warning ( '请先选择 XLSX 文件' )
return
}
importing . value = true
importResult . value = null
try {
2026-08-06 14:55:17 +08:00
importResult . value = await importPlatformPartners ( importFile . value )
2026-08-03 14:58:17 +08:00
if ( importResult . value . success ) {
ElMessage . success ( `成功导入 ${ importResult . value . importedRows } 条往来单位` )
await loadPartners ()
}
} catch ( error ) {
const meta = getApiErrorDetails ( error , '往来单位导入失败,请重试' )
ElMessage . error ( meta . requestId ? ` ${ meta . message } ( requestId: ${ meta . requestId } ) ` : meta . message )
} finally {
importing . value = false
}
}
function downloadImportErrors () {
if ( ! importResult . value ? . errors . length ) return
const escapeCsv = ( value : string | number ) => `" ${ String ( value ). replaceAll ( '"' , '""' ) } "`
const rows = [
[ '行号' , '字段' , '原因' ],
... importResult . value . errors . map (( error ) => [ error . row || '-' , error . field , error . message ]),
]
const csv = rows . map (( row ) => row . map ( escapeCsv ). join ( ',' )). join ( '\r\n' )
const url = URL . createObjectURL ( new Blob ([ '\ufeff' , csv ], { type : 'text/csv;charset=utf-8' }))
const link = document . createElement ( 'a' )
link . href = url
link . download = ` ${ importResult . value . fileName . replace ( /\.xlsx$/i , '' ) } -错误报告.csv`
link . click ()
URL . revokeObjectURL ( url )
}
2026-08-01 12:17:33 +08:00
async function savePartner () {
const valid = await formRef . value ? . validate (). catch (() => false )
if ( ! valid ) return
saving . value = true
try {
const payload = savePayload ()
if ( form . id ) {
2026-08-06 14:55:17 +08:00
await updatePlatformPartner ( form . id , payload )
2026-08-01 12:17:33 +08:00
ElMessage . success ( '往来单位已更新' )
} else {
2026-08-06 14:55:17 +08:00
await createPlatformPartner ( payload )
2026-08-01 12:17:33 +08:00
ElMessage . success ( '往来单位已新增' )
}
editorOpen . value = false
await loadPartners ()
} 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 : PlatformPartnerResource ) {
const nextStatus : PlatformPartnerStatus = row . status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox . confirm (
` ${ action } 后 ${ nextStatus === 'ACTIVE' ? '可继续用于新业务' : '不再用于新业务,历史数据不受影响' } 。` ,
` ${ action } “ ${ row . partnerName } ”` ,
{ type : nextStatus === 'ACTIVE' ? 'success' : 'warning' , confirmButtonText : action , cancelButtonText : '取消' },
)
} catch {
return
}
const id = String ( row . id )
statusLoadingIds . value = new Set ( statusLoadingIds . value ). add ( id )
try {
2026-08-06 14:55:17 +08:00
await changePlatformPartnerStatus ( row . id , nextStatus )
2026-08-01 12:17:33 +08:00
ElMessage . success ( `往来单位已 ${ action } ` )
await loadPartners ()
} 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
}
}
function formatUpdatedAt ( value ?: string | null ) {
return value ? value . replace ( 'T' , ' ' ). slice ( 0 , 16 ) : '-'
}
watch ([ page , pageSize ], () => { void loadPartners () })
2026-08-04 21:01:39 +08:00
onMounted (() => {
void loadPartners ()
2026-08-04 09:05:19 +08:00
})
2026-08-01 12:17:33 +08:00
</ script >
< template >
< div class = "business-page partners-page" >
2026-08-03 11:45:45 +08:00
< QueryToolbar v-model = "filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners" />
2026-08-01 12:17:33 +08:00
2026-08-04 21:01:39 +08:00
2026-08-01 12:17:33 +08:00
< section v-if = "loadError" class="surface-panel partners-error" role="alert" >
< div >
< strong > 往来单位列表暂时无法加载 </ strong >
< p >{{ loadError . message }}</ p >
< ApiErrorMeta :details = "loadError" />
</ div >
2026-08-04 09:05:19 +08:00
< el-button type = "primary" :loading = "loading" @click ="loadPartners()" > 重新加载 </ el -button >
2026-08-01 12:17:33 +08:00
</ section >
< section v-else class = "surface-panel table-panel" :aria-busy = "loading" >
2026-08-03 11:45:45 +08:00
< div class = "partners-table-header" >
< div class = "partners-table-heading" >
< h2 > 往来单位列表 </ h2 >
2026-08-06 14:55:17 +08:00
< span > 平台公共数据 · 共 {{ total }} 个往来单位 </ span >
2026-08-03 11:45:45 +08:00
</ div >
2026-08-03 14:58:17 +08:00
< div class = "partners-primary-actions" >
2026-08-04 21:01:39 +08:00
< el-button v-if = "access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><Upload :size="16" /> 批量导入 </ el-button >
< el-button v-if = "access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" /> 新增往来单位 </ el-button >
2026-08-03 14:58:17 +08:00
</ div >
2026-08-03 11:45:45 +08:00
</ div >
2026-08-01 12:17:33 +08:00
< DataTable
v -model:page = "page"
v -model:page-size = "pageSize"
:data = "rows"
:columns = "partnerColumns"
:loading = "loading"
:total = "total"
: page -sizes ="[ 20 , 50 , 100 ]"
aria -label =" 往来单位列表 "
row -key =" id "
empty -text =" 暂无符合条件的往来单位 "
>
< template # actions = "{ row }" >
2026-08-03 14:58:17 +08:00
< div class = "table-actions" >
< el-button link type = "primary" @click ="openDetail(row as PlatformPartnerResource)" >< Eye :size = "15" /> 详情 </ el-button >
2026-08-04 21:01:39 +08:00
< el-button v-if = "access.can('platform:counterparty:edit')" link type="primary" :disabled="loading" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" /> 编辑 </ el-button >
< el-button v-if = "access.can('platform:counterparty:status')" link :disabled="loading" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ ( row as PlatformPartnerResource ). status === 'ACTIVE' ? '停用' : '启用' }}</ el-button >
2026-08-01 12:17:33 +08:00
</ div >
</ template >
< template # partner = "{ row }" >< div class = "partner-cell" >< span >< Building2 :size = "18" /></ span >< div >< strong >{{ ( row as PlatformPartnerResource ). partnerName }}</ strong >< small >{{ ( row as PlatformPartnerResource ). partnerCode }}</ small ></ div ></ div ></ template >
2026-08-03 14:58:17 +08:00
< template # type = "{ row }" >< div class = "partner-types" >< el-tag v-for = "type in (row as PlatformPartnerResource).types" :key="type" effect="plain" >{{ partnerTypeLabel ( type ) }} </ el -tag ></ div ></ template >
2026-08-01 12:17:33 +08:00
< template # contact = "{ row }" >< strong >{{ ( row as PlatformPartnerResource ). contactName }}</ strong >< span class = "partner-subline" >{{ ( row as PlatformPartnerResource ). contactPhone }}</ span >< span v-if = "(row as PlatformPartnerResource).contactEmail" class="partner-subline" >{{ ( row as PlatformPartnerResource ) .contactEmail }}</ span ></ template >
< template # address = "{ row }" >< div class = "partner-address" >< MapPin :size = "15" />< span >{{ [( row as PlatformPartnerResource ). province , ( row as PlatformPartnerResource ). city , ( row as PlatformPartnerResource ). district , ( row as PlatformPartnerResource ). addressLine ]. filter ( Boolean ). join ( ' ' ) }}</ span ></ div ></ template >
< template # status = "{ row }" >< el-tag : type = "(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect = "light" >{{ ( row as PlatformPartnerResource ). status === 'ACTIVE' ? '启用' : '停用' }}</ el-tag ></ template >
2026-08-03 11:45:45 +08:00
< template # pagination -left />
2026-08-01 12:17:33 +08:00
</ DataTable >
</ section >
< el-drawer v-model = "editorOpen" :title="editorLabel" size="680px" 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 = "partnerCode" required >< el-input v-model = "form.partnerCode" placeholder="如:CUS-001" maxlength="50" /></ el-form-item >
< el-form-item label = "单位名称" prop = "partnerName" required >< el-input v-model = "form.partnerName" placeholder="请输入工商登记或常用单位名称" maxlength="128" /></ el-form-item >
2026-08-03 14:58:17 +08:00
< el-form-item label = "单位类型" prop = "types" required >< el-select v-model = "form.types" multiple collapse-tags collapse-tags-tooltip placeholder="请选择一个或多个单位类型"><el-option v-for="option in typeOptions" :key="option.value" :label="option.label" :value="option.value" /></ el-select ></ el-form-item >
2026-08-01 12:17:33 +08:00
< 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 >
< div class = "form-section" >< h3 > 联系信息 </ h3 >< div class = "form-grid two-cols" >
< el-form-item label = "联系人" prop = "contactName" required >< el-input v-model = "form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></ el-form-item >
< el-form-item label = "联系电话" prop = "contactPhone" required >< el-input v-model = "form.contactPhone" placeholder="请输入手机号或固定电话" maxlength="32" /></ el-form-item >
< el-form-item label = "联系邮箱" prop = "contactEmail" >< el-input v-model = "form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></ el-form-item >
</ div ></ div >
< div class = "form-section" >< h3 > 地址信息 </ h3 >< div class = "form-grid two-cols" >
< el-form-item label = "国家二字码" prop = "countryCode" required >< el-input v-model = "form.countryCode" placeholder="如:CN" maxlength="2" @input="form.countryCode = String($event).toUpperCase()" /></ el-form-item >
< el-form-item label = "省 / 州" prop = "province" >< el-input v-model = "form.province" placeholder="如:广东省" maxlength="64" /></ el-form-item >
< el-form-item label = "城市" prop = "city" required >< el-input v-model = "form.city" placeholder="如:深圳市" maxlength="64" /></ el-form-item >
< el-form-item label = "区 / 县" prop = "district" >< el-input v-model = "form.district" placeholder="如:宝安区" maxlength="64" /></ el-form-item >
< el-form-item label = "邮政编码" prop = "postalCode" >< el-input v-model = "form.postalCode" placeholder="如:518000" maxlength="20" /></ el-form-item >
</ div >< el-form-item label = "详细地址" prop = "addressLine" required >< el-input v-model = "form.addressLine" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及门牌信息" maxlength="255" show -word-limit /></ el-form-item ></ div >
2026-08-03 14:58:17 +08:00
< div class = "form-section" >< h3 > 其他信息 </ h3 >< el-form-item label = "备注" prop = "remark" >< el-input v-model = "form.remark" type="textarea" :rows="3" placeholder="请输入内部说明(选填)" maxlength="500" show -word-limit /></ el-form-item ></ div >
2026-08-01 12:17:33 +08:00
</ el-form >
< template # footer >< div class = "drawer-footer" >< el-button :disabled = "saving" @click ="editorOpen = false" > 取消 </ el -button >< el-button type = "primary" :loading = "saving" @click ="savePartner" > 保存往来单位 </ el -button ></ div ></ template >
</ el-drawer >
2026-08-03 14:58:17 +08:00
< el-drawer v-model = "detailOpen" title="往来单位详情" size="680px" class="business-drawer detail-drawer" >
< div v-loading = "detailLoading" class="partner-detail" >
< template v-if = "detail" >
< section >< h3 > 单位信息 </ h3 >< dl class = "detail-grid" >
< div >< dt > 单位编码 </ dt >< dd >{{ detail . partnerCode }}</ dd ></ div >
< div >< dt > 单位名称 </ dt >< dd >{{ detail . partnerName }}</ dd ></ div >
< div >< dt > 状态 </ dt >< dd >< el-tag : type = "detail.status === 'ACTIVE' ? 'success' : 'info'" >{{ detail . status === 'ACTIVE' ? '启用' : '停用' }}</ el-tag ></ dd ></ div >
< div >< dt > 单位类型 </ dt >< dd class = "partner-types" >< el-tag v-for = "type in detail.types" :key="type" effect="plain" >{{ partnerTypeLabel ( type ) }} </ el -tag ></ dd ></ div >
</ dl ></ section >
< section >< h3 > 联系与地址 </ h3 >< dl class = "detail-grid" >
< div >< dt > 联系人 </ dt >< dd >{{ detail . contactName }}</ dd ></ div >< div >< dt > 联系电话 </ dt >< dd >{{ detail . contactPhone }}</ dd ></ div >
< div >< dt > 联系邮箱 </ dt >< dd >{{ detail . contactEmail || '-' }}</ dd ></ div >< div >< dt > 邮政编码 </ dt >< dd >{{ detail . postalCode || '-' }}</ dd ></ div >
< div class = "detail-span" >< dt > 详细地址 </ dt >< dd >{{ [ detail . countryCode , detail . province , detail . city , detail . district , detail . addressLine ]. filter ( Boolean ). join ( ' ' ) }}</ dd ></ div >
</ dl ></ section >
< section >< h3 > 其他信息 </ h3 >< dl class = "detail-grid" >
2026-08-06 14:55:17 +08:00
< div class = "detail-span" >< dt > 更新时间 </ dt >< dd >{{ formatUpdatedAt ( detail . updatedAt ) }}</ dd ></ div >
2026-08-03 14:58:17 +08:00
< div class = "detail-span" >< dt > 备注 </ dt >< dd >{{ detail . remark || '-' }}</ dd ></ div >
</ dl ></ section >
</ template >
</ div >
</ el-drawer >
< el-dialog v-model = "importOpen" title="批量导入往来单位" width="min(720px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing" >
< div class = "import-content" >
< div class = "import-guide" >< strong > 使用标准模板导入 </ strong >< p > 仅新增档案 , 最多 1000 行 。 服务端会整批校验 ; 任一行有误时不会写入任何数据 。 </ p >< el-button :loading = "downloadingTemplate" @click ="downloadTemplate" >< Download :size = "16" /> 下载 XLSX 模板 </ el-button ></ div >
< el-upload accept = ".xlsx" :auto-upload = "false" :limit = "1" :on-change = "selectImportFile" :on-remove = "removeImportFile" >
< el-button >< Upload :size = "16" /> 选择 XLSX 文件 </ el-button >
< template # tip >< div class = "el-upload__tip" > 文件不得超过 5 MB , 请勿修改模板版本或表头 。 </ div ></ template >
</ el-upload >
< div v-if = "importResult" :class="['import-result', importResult.success ? 'is-success' : 'is-error']" role="status" >
< div class = "import-result-heading" >< strong >{{ importResult . success ? `已成功导入 ${ importResult . importedRows } 条记录` : `发现 ${ importResult . errors . length } 个问题,未写入数据` }}</ strong >< el-button v-if = "importResult.errors.length" size="small" @click="downloadImportErrors"><Download :size="15" /> 下载错误报告 </ el-button ></ div >
< el-table v-if = "importResult.errors.length" :data="importResult.errors" max-height="260" size="small" >
< el-table-column prop = "row" label = "行号" width = "70" >< template # default = "{ row }" >{{ row . row || '-' }}</ template ></ el-table-column >
< el-table-column prop = "field" label = "字段" width = "120" />< el-table-column prop = "message" label = "原因" min -width =" 240 " />
</ el-table >
</ div >
</ div >
< template # footer >< el-button :disabled = "importing" @click ="importOpen = false" > 关闭 </ el -button >< el-button type = "primary" :loading = "importing" :disabled = "!importFile" @click ="submitImport" > 开始导入 </ el -button ></ template >
</ el-dialog >
2026-08-01 12:17:33 +08:00
</ div >
</ template >
< style scoped >
. partners - error { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 18 px 20 px ; border - color : # fecdca ; background : # fffafa ; }
. partners - error strong { color : # 7 a271a ; font - size : 13 px ; }
. partners - error p { margin : 4 px 0 0 ; color : # b54708 ; font - size : 12 px ; overflow - wrap : anywhere ; }
2026-08-03 11:45:45 +08:00
. partners - table - header { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 16 px 18 px ; border - bottom : 1 px solid var ( -- cc - border ); }
. partners - table - heading { display : flex ; min - width : 0 ; align - items : baseline ; gap : 10 px ; }
. partners - table - heading h2 { margin : 0 ; color : var ( -- cc - text ); font - size : 16 px ; line - height : 1.4 ; }
. partners - table - heading span { color : var ( -- cc - muted ); font - size : 12 px ; white - space : nowrap ; }
2026-08-03 14:58:17 +08:00
. partners - primary - actions , . partner - types { display : flex ; flex - wrap : wrap ; gap : 8 px ; }
2026-08-01 12:17:33 +08:00
. partner - cell { display : flex ; align - items : center ; gap : 10 px ; }
. partner - 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 ); }
. partner - cell > div { display : flex ; min - width : 0 ; flex - direction : column ; gap : 3 px ; }
. partner - cell strong { overflow - wrap : anywhere ; }
. partner - cell small , . partner - subline { display : block ; color : var ( -- cc - muted ); font - size : 11 px ; line - height : 1.45 ; overflow - wrap : anywhere ; }
. partner - address { display : flex ; align - items : flex - start ; gap : 7 px ; line - height : 1.55 ; overflow - wrap : anywhere ; }
. partner - address svg { flex : 0 0 15 px ; margin - top : 3 px ; color : var ( -- cc - muted ); }
2026-08-06 14:55:17 +08:00
. partner - detail { min - height : 180 px ; padding : 20 px 24 px 28 px ; box - sizing : border - box ; }
. partner - detail section + section { margin - top : 28 px ; }
2026-08-03 14:58:17 +08:00
. partner - detail h3 { margin : 0 0 12 px ; color : var ( -- cc - text ); font - size : 15 px ; }
. detail - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 1 px ; margin : 0 ; overflow : hidden ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; background : var ( -- cc - border ); }
. detail - grid > div { min - width : 0 ; padding : 12 px 14 px ; background : # fff ; }
. detail - grid . detail - span { grid - column : 1 / - 1 ; }
. detail - grid dt { margin - bottom : 5 px ; color : var ( -- cc - muted ); font - size : 12 px ; }
. detail - grid dd { margin : 0 ; color : var ( -- cc - text ); line - height : 1.55 ; overflow - wrap : anywhere ; }
. import - content { display : grid ; gap : 18 px ; }
. import - guide { padding : 16 px ; border : 1 px solid # d8e2f0 ; border - radius : 8 px ; background : # f8fbff ; }
. import - guide p { margin : 6 px 0 14 px ; color : var ( -- cc - muted ); font - size : 13 px ; line - height : 1.6 ; }
. import - result { display : grid ; gap : 12 px ; padding : 14 px ; border : 1 px solid ; border - radius : 8 px ; }
. import - result - heading { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; }
. import - result . is - success { border - color : # a6f4c5 ; background : # f6fef9 ; color : # 067647 ; }
. import - result . is - error { border - color : # fecdca ; background : # fffafa ; color : # b42318 ; }
2026-08-01 12:17:33 +08:00
@ media ( max - width : 720 px ) {
. partners - error { align - items : stretch ; flex - direction : column ; }
. partners - error . el - button { min - height : 44 px ; }
2026-08-03 11:45:45 +08:00
. partners - table - header { align - items : stretch ; flex - direction : column ; }
. partners - table - header . el - button { width : 100 % ; min - height : 40 px ; }
2026-08-03 14:58:17 +08:00
. partners - primary - actions { flex - direction : column ; }
2026-08-06 14:55:17 +08:00
. partner - detail { padding : 16 px ; }
2026-08-03 14:58:17 +08:00
. detail - grid { grid - template - columns : minmax ( 0 , 1 fr ); }
. detail - grid . detail - span { grid - column : auto ; }
. import - result - heading { align - items : stretch ; flex - direction : column ; }
2026-08-01 12:17:33 +08:00
}
</ style >