Files
admin-web/src/modules/master-data/counterparties/pages/PartnersView.vue
T
2026-08-06 14:55:17 +08:00

574 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { Building2, Download, Eye, MapPin, Pencil, Plus, Power, Upload } from '@lucide/vue'
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'
import {
changePlatformPartnerStatus,
createPlatformPartner,
downloadPlatformPartnerImportTemplate,
getPlatformPartner,
importPlatformPartners,
listPlatformPartners,
updatePlatformPartner,
type PlatformPartnerImportResult,
type PlatformPartnerResource,
type PlatformPartnerStatus,
type PlatformPartnerLegacyType,
type PlatformPartnerType,
type SavePlatformPartnerRequest,
} from '@/modules/master-data/counterparties/api/platformPartners'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const pageSize = ref(20)
const access = useAccessPreviewStore()
const loading = ref(false)
const saving = ref(false)
const editorOpen = ref(false)
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)
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>())
let loadSequence = 0
type PartnerFilters = QueryModel & {
keyword: string
type: PlatformPartnerType | ''
status: PlatformPartnerStatus | ''
}
const filters = ref<PartnerFilters>({
keyword: '',
type: '',
status: '',
})
const appliedFilters = ref<PartnerFilters>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', placeholder: '单位名称、编码、联系人或电话' },
{
key: 'type',
type: 'select',
placeholder: '全部类型',
options: [
{ label: '客户', value: 'CUSTOMER' },
{ label: '供应商', value: 'SUPPLIER' },
{ label: '承运商', value: 'CARRIER' },
{ label: '报关行', value: 'CUSTOMS_BROKER' },
{ label: '发货单位', value: 'SHIPPER' },
{ label: '收货单位', value: 'CONSIGNEE' },
],
},
{
key: 'status',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
},
])
interface PartnerForm extends SavePlatformPartnerRequest {
id: string
}
const emptyForm = (): PartnerForm => ({
id: '',
partnerCode: '',
partnerName: '',
partnerType: 'CUSTOMER',
types: ['CUSTOMER'],
contactName: '',
contactPhone: '',
contactEmail: null,
countryCode: 'CN',
province: null,
city: '',
district: null,
addressLine: '',
postalCode: null,
status: 'ACTIVE',
remark: null,
})
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' }],
types: [{ required: true, type: 'array', min: 1, message: '请至少选择一个单位类型', trigger: 'change' }],
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: '供应商',
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]
}
const partnerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ 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,
}
}
async function loadPartners() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformPartners(queryParams())
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 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,
partnerType: row.types[0],
types: [...row.types],
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,
remark: row.remark,
})
captureBaseline()
editorOpen.value = true
requestAnimationFrame(() => formRef.value?.clearValidate())
}
function savePayload(): SavePlatformPartnerRequest {
const types = [...form.types]
return {
partnerCode: form.partnerCode.trim(),
partnerName: form.partnerName.trim(),
partnerType: types[0],
types,
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,
remark: nullable(form.remark),
}
}
async function openDetail(row: PlatformPartnerResource) {
detailOpen.value = true
detailLoading.value = true
detail.value = null
try {
detail.value = await getPlatformPartner(row.id)
} 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 {
importResult.value = await importPlatformPartners(importFile.value)
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)
}
async function savePartner() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = savePayload()
if (form.id) {
await updatePlatformPartner(form.id, payload)
ElMessage.success('往来单位已更新')
} else {
await createPlatformPartner(payload)
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 {
await changePlatformPartnerStatus(row.id, nextStatus)
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() })
onMounted(() => {
void loadPartners()
})
</script>
<template>
<div class="business-page partners-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners" />
<section v-if="loadError" class="surface-panel partners-error" role="alert">
<div>
<strong>往来单位列表暂时无法加载</strong>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
</div>
<el-button type="primary" :loading="loading" @click="loadPartners()">重新加载</el-button>
</section>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<div class="partners-table-header">
<div class="partners-table-heading">
<h2>往来单位列表</h2>
<span>平台公共数据 · 共 {{ total }} 个往来单位</span>
</div>
<div class="partners-primary-actions">
<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>
</div>
</div>
<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 }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
<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>
</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>
<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>
<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>
<template #pagination-left />
</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>
<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>
<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>
<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>
</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>
<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">
<div class="detail-span"><dt>更新时间</dt><dd>{{ formatUpdatedAt(detail.updatedAt) }}</dd></div>
<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">文件不得超过 5MB,请勿修改模板版本或表头。</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>
</div>
</template>
<style scoped>
.partners-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; }
.partners-error strong { color: #7a271a; font-size: 13px; }
.partners-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
.partners-table-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid var(--cc-border); }
.partners-table-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; }
.partners-table-heading h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
.partners-table-heading span { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.partners-primary-actions, .partner-types { display: flex; flex-wrap: wrap; gap: 8px; }
.partner-cell { display: flex; align-items: center; gap: 10px; }
.partner-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.partner-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.partner-cell strong { overflow-wrap: anywhere; }
.partner-cell small, .partner-subline { display: block; color: var(--cc-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.partner-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
.partner-address svg { flex: 0 0 15px; margin-top: 3px; color: var(--cc-muted); }
.partner-detail { min-height: 180px; padding: 20px 24px 28px; box-sizing: border-box; }
.partner-detail section + section { margin-top: 28px; }
.partner-detail h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-border); }
.detail-grid > div { min-width: 0; padding: 12px 14px; background: #fff; }
.detail-grid .detail-span { grid-column: 1 / -1; }
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 0; color: var(--cc-text); line-height: 1.55; overflow-wrap: anywhere; }
.import-content { display: grid; gap: 18px; }
.import-guide { padding: 16px; border: 1px solid #d8e2f0; border-radius: 8px; background: #f8fbff; }
.import-guide p { margin: 6px 0 14px; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
.import-result { display: grid; gap: 12px; padding: 14px; border: 1px solid; border-radius: 8px; }
.import-result-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.import-result.is-success { border-color: #a6f4c5; background: #f6fef9; color: #067647; }
.import-result.is-error { border-color: #fecdca; background: #fffafa; color: #b42318; }
@media (max-width: 720px) {
.partners-error { align-items: stretch; flex-direction: column; }
.partners-error .el-button { min-height: 44px; }
.partners-table-header { align-items: stretch; flex-direction: column; }
.partners-table-header .el-button { width: 100%; min-height: 40px; }
.partners-primary-actions { flex-direction: column; }
.partner-detail { padding: 16px; }
.detail-grid { grid-template-columns: minmax(0, 1fr); }
.detail-grid .detail-span { grid-column: auto; }
.import-result-heading { align-items: stretch; flex-direction: column; }
}
</style>