Files
shanxia2.0/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue
T
2026-05-24 23:04:33 +08:00

1153 lines
33 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.
<!-- 渠道信息 — 只读查看(样式对齐采集协议单 ProtocolViewDialog) -->
<template>
<div class="channel-view-dialog-root">
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1100px"
class="dialog-form-layout channel-view-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<div class="channel-view-body" :class="{ 'is-sub-loading': loading && channel }">
<template v-if="channel">
<div v-loading="loading" class="detail-tabs">
<div
v-for="tab in viewTabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeTab === tab.key }"
@click="scrollToSection(tab.key)"
>
{{ tab.label }}
</div>
</div>
<div class="kpi-strip">
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><OfficeBuilding /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">渠道名称</div>
<div class="kpi-value kpi-value--emphasis">{{ dash(channel.channelName) }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><CollectionTag /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">渠道类型</div>
<div class="kpi-value">
<el-tag :type="channelTypeTagType(channel.channelType)" size="small">
{{ channelTypeLabel(channel.channelType) }}
</el-tag>
</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><User /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">联系人</div>
<div class="kpi-value">{{ dash(channel.contactName) }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><Calendar /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">创建时间</div>
<div class="kpi-value">{{ dash(channel.createTime) }}</div>
</div>
</div>
</div>
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title"><h3>基础信息</h3></div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">渠道名称</div>
<div class="data-value">{{ dash(channel.channelName) }}</div>
</div>
<div class="data-item">
<div class="data-label">统一社会信用代码</div>
<div class="data-value">{{ dash(channel.creditCode) }}</div>
</div>
<div class="data-item">
<div class="data-label">渠道类型</div>
<div class="data-value">
<el-tag :type="channelTypeTagType(channel.channelType)" size="small">
{{ channelTypeLabel(channel.channelType) }}
</el-tag>
</div>
</div>
<div class="data-item">
<div class="data-label">联系人</div>
<div class="data-value">{{ dash(channel.contactName) }}</div>
</div>
<div class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(channel.contactPhone) }}</div>
</div>
<div class="data-item">
<div class="data-label">联系邮箱</div>
<div class="data-value">{{ dash(channel.contactEmail) }}</div>
</div>
<div class="data-item">
<div class="data-label">创建时间</div>
<div class="data-value">{{ dash(channel.createTime) }}</div>
</div>
</div>
<div v-if="protocolAttachmentFiles.length" class="attachment-row">
<div class="data-label">渠道协议附件</div>
<ProtocolAttachmentReadonly
:files="protocolAttachmentFiles"
@preview="openPreview"
@download="downloadFile"
/>
</div>
<div v-if="channel.remark" class="remark-row">
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(channel.remark) }}</div>
</div>
</div>
</div>
<div id="view-section-invoice" class="section-card section-card--green">
<div class="section-title"><h3>开票信息</h3></div>
<div v-if="invoiceList.length" class="sub-cards">
<div v-for="(row, idx) in invoiceList" :key="row.id || idx" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.companyName || '未命名' }}</h4>
</div>
<div class="data-grid data-grid--2">
<div v-if="row.taxNo" class="data-item">
<div class="data-label">税号</div>
<div class="data-value">{{ dash(row.taxNo) }}</div>
</div>
<div v-if="row.phone || row.companyPhone" class="data-item">
<div class="data-label">电话</div>
<div class="data-value">{{ dash(row.phone || row.companyPhone) }}</div>
</div>
<div v-if="row.companyAddress" class="data-item data-item--full">
<div class="data-label">单位地址</div>
<div class="data-value data-value--muted">{{ dash(row.companyAddress) }}</div>
</div>
<div v-if="row.bankName" class="data-item">
<div class="data-label">开户银行</div>
<div class="data-value">{{ dash(row.bankName) }}</div>
</div>
<div v-if="row.bankAccount" class="data-item">
<div class="data-label">银行账号</div>
<div class="data-value">{{ dash(row.bankAccount) }}</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无开票信息" :image-size="48" />
</div>
<div id="view-section-mail" class="section-card section-card--white">
<div class="section-title"><h3>邮寄地址</h3></div>
<div v-if="mailList.length" class="sub-cards">
<div v-for="(row, idx) in mailList" :key="row.id || idx" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.mailAddress || '未命名' }}</h4>
</div>
<div class="data-grid data-grid--2">
<div v-if="row.mailContactName || row.mailContact" class="data-item">
<div class="data-label">联系人</div>
<div class="data-value">{{ dash(row.mailContactName || row.mailContact) }}</div>
</div>
<div v-if="row.mailContactPhone || row.mailPhone" class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(row.mailContactPhone || row.mailPhone) }}</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无邮寄地址" :image-size="48" />
</div>
<div id="view-section-account" class="section-card section-card--white">
<div class="section-title"><h3>账户信息</h3></div>
<div class="account-balance-card">
<div class="account-balance-card__label">账户余额</div>
<div class="account-balance-card__value">
¥ {{ accountLoading ? '—' : formatAmount(accountData?.balanceAmount) }}
</div>
</div>
<div class="account-quotas-head">
<h4 class="account-quotas-title">产品份数</h4>
</div>
<div v-if="quotasLoading" v-loading="true" style="min-height: 120px"></div>
<div v-else-if="productQuotas.length" class="list-body list-wrap account-quota-list-wrap">
<el-table
:data="productQuotas"
class="list-table account-quota-table"
stripe
row-key="id"
max-height="360"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="cellTypeName"
label="细胞类型"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="totalQty"
label="总份数"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="usedQty"
label="已用份数"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="remainingQty"
label="剩余份数"
min-width="100"
align="center"
header-align="center"
/>
</el-table>
</div>
<el-empty v-else description="暂无产品份数数据" :image-size="48" />
</div>
<div id="view-section-agreement" class="section-card">
<div class="section-title"><h3>协议信息</h3></div>
<div v-if="agreementList.length" class="agreement-cards">
<div
v-for="(agr, idx) in agreementList"
:key="agr.id || idx"
class="agreement-card"
>
<div class="agreement-card__header">
<div class="agreement-card__tags">
<el-tag :type="agreementTypeTagType(agr.agreementType)" size="small">
{{ agreementTypeLabel(agr.agreementType) }}
</el-tag>
<el-tag :type="agreementStatusTagType(agr.status)" size="small">
{{ agreementStatusLabel(agr.status) }}
</el-tag>
</div>
</div>
<div class="data-grid data-grid--4 agreement-card__info">
<div v-if="agr.settlementType" class="data-item">
<div class="data-label">结算方式</div>
<div class="data-value">
<el-tag :type="settlementTypeTagType(agr.settlementType)" size="small">
{{ settlementTypeLabel(agr.settlementType) }}
</el-tag>
</div>
</div>
<div v-if="agr.creditLimit != null" class="data-item">
<div class="data-label">授信额度(元)</div>
<div class="data-value data-value--price">{{ dash(agr.creditLimit) }}</div>
</div>
<div v-if="agr.effectiveDate" class="data-item">
<div class="data-label">生效日期</div>
<div class="data-value">{{ dash(agr.effectiveDate) }}</div>
</div>
<div v-if="agr.expiryDate" class="data-item">
<div class="data-label">到期日期</div>
<div class="data-value">{{ dash(agr.expiryDate) }}</div>
</div>
</div>
<div class="agreement-card__products">
<div class="agreement-products-head">
<h4 class="agreement-products-title">细胞产品信息</h4>
</div>
<div
v-if="(agreementPricesMap[agr.id] || []).length > 0"
class="list-body list-wrap agreement-price-list-wrap"
>
<el-table
:data="agreementPricesMap[agr.id] || []"
class="list-table agreement-price-table"
stripe
row-key="id"
max-height="360"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="projectCode"
label="项目编码"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="cellTypeName"
label="细胞类型"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
label="原价(元)"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row: priceRow }">
{{ formatPrice(priceRow.originalPrice) }}
</template>
</el-table-column>
<el-table-column
label="协议价(元)"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row: priceRow }">
<span class="data-value--price">{{ formatPrice(priceRow.agreementPrice) }}</span>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
</el-table>
</div>
<el-empty v-else description="暂无细胞产品信息" :image-size="48" class="agreement-price-empty" />
</div>
</div>
</div>
<el-empty v-else description="暂无协议信息" :image-size="48" />
</div>
</template>
<div v-else v-loading="true" style="min-height: 200px"></div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
alt="预览"
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
<div v-else class="preview-container">
<video
:src="previewUrl"
controls
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Calendar, Close, CollectionTag, OfficeBuilding, User } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
channelTypeLabel,
channelTypeTagType,
agreementTypeLabel,
agreementTypeTagType,
agreementStatusLabel,
agreementStatusTagType,
settlementTypeLabel,
settlementTypeTagType
} from '@/utils/status-tag-registry'
import {
fetchChannelInvoices,
fetchChannelMailAddresses,
fetchChannelAgreements,
fetchChannelAgreementPrices,
fetchChannelAccount,
fetchChannelProductQuotas
} from '@/api/admin'
const props = defineProps<{
modelValue: boolean
channelId: string | null
listRow?: Record<string, any> | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
}>()
const viewTabs = [
{ key: 'basic', label: '基础信息' },
{ key: 'invoice', label: '开票信息' },
{ key: 'mail', label: '邮寄地址' },
{ key: 'agreement', label: '协议信息' },
{ key: 'account', label: '账户信息' }
] as const
const loading = ref(false)
const activeTab = ref('basic')
const invoiceList = ref<any[]>([])
const mailList = ref<any[]>([])
const agreementList = ref<any[]>([])
const agreementPricesMap = ref<Record<string, any[]>>({})
const accountData = ref<{ balanceAmount: number } | null>(null)
const productQuotas = ref<any[]>([])
const accountLoading = ref(false)
const quotasLoading = ref(false)
const accountLoaded = ref(false)
const channel = computed(() => props.listRow || null)
const dialogTitle = computed(() => {
const name = channel.value?.channelName
return name ? `查看渠道 — ${name}` : '查看渠道'
})
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const protocolAttachmentFiles = computed(() => {
const row = channel.value
if (!row) return []
if (Array.isArray(row.attachmentFiles) && row.attachmentFiles.length) {
return row.attachmentFiles as Array<Record<string, any>>
}
const urls = String(row.protocolAttachments || '').trim()
if (!urls) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `att_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
})
.filter(Boolean) as Array<Record<string, any>>
})
function dash(val: unknown) {
if (val === null || val === undefined || val === '') return '—'
return String(val)
}
function formatPrice(val: unknown) {
if (val === null || val === undefined || val === '') return '—'
return String(val)
}
function normalizeAgreementRow(row: Record<string, any>) {
const status = String(row.agreementStatus ?? row.status ?? 'dsx').trim() || 'dsx'
return {
...row,
settlementType: row.settleMode ?? row.settlementType ?? '',
creditLimit: row.creditLimitAmount ?? row.creditLimit ?? null,
expiryDate: row.expireDate ?? row.expiryDate ?? '',
effectiveDate: row.effectiveDate ?? '',
status
}
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
return suffix
}
return `${minioBaseUrl}/${suffix}`
}
function isVideoFile(file: { fileType?: string; fileName?: string }) {
const t = (file.fileType || '').toLowerCase()
if (t.startsWith('video/')) return true
return /\.(mp4|webm|ogg|mov)$/i.test(file.fileName || '')
}
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
function openPreview(file: Record<string, any>) {
if (!file.fileUrl) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewType.value = isVideoFile(file) ? 'video' : 'image'
previewVisible.value = true
}
function downloadFile(file: Record<string, any>) {
downloadAttachmentFile(file, { fullFileUrl })
}
function formatAmount(val: unknown) {
if (val === null || val === undefined || val === '') return '0.00'
const num = Number(val)
if (isNaN(num)) return '0.00'
return num.toFixed(2)
}
async function loadAccountData() {
if (!props.channelId || accountLoaded.value) return
accountLoading.value = true
quotasLoading.value = true
try {
const [accRes, quotaRes] = await Promise.all([
fetchChannelAccount(props.channelId),
fetchChannelProductQuotas(props.channelId)
])
accountData.value = (accRes as any)?.data ?? accRes ?? null
const quotaRows = Array.isArray(quotaRes) ? quotaRes : (quotaRes as any)?.list || []
productQuotas.value = quotaRows
accountLoaded.value = true
} catch {
accountData.value = null
productQuotas.value = []
} finally {
accountLoading.value = false
quotasLoading.value = false
}
}
async function loadSubTables() {
if (!props.channelId) {
invoiceList.value = []
mailList.value = []
agreementList.value = []
return
}
loading.value = true
try {
const [invRes, mailRes, agrRes] = await Promise.all([
fetchChannelInvoices(props.channelId),
fetchChannelMailAddresses(props.channelId),
fetchChannelAgreements(props.channelId)
])
const invRows = Array.isArray(invRes) ? invRes : (invRes as any)?.list || []
const mailRows = Array.isArray(mailRes) ? mailRes : (mailRes as any)?.list || []
invoiceList.value = invRows.map((row: any) => ({
...row,
phone: row.companyPhone ?? row.phone ?? '',
companyPhone: row.companyPhone ?? row.phone ?? ''
}))
mailList.value = mailRows.map((row: any) => ({
...row,
mailContactName: row.mailContact ?? row.mailContactName ?? '',
mailContactPhone: row.mailPhone ?? row.mailContactPhone ?? ''
}))
const agrRows = Array.isArray(agrRes) ? agrRes : (agrRes as any)?.list || []
agreementList.value = agrRows.map((row: Record<string, any>) => normalizeAgreementRow(row))
const entries = await Promise.all(
agreementList.value.map(async (agr: any) => {
if (!agr.id) return [agr.id, []] as const
try {
const priceRes = await fetchChannelAgreementPrices(agr.id)
const prices = Array.isArray(priceRes)
? priceRes
: (priceRes as any)?.list || []
return [agr.id, prices] as const
} catch {
return [agr.id, []] as const
}
})
)
const map: Record<string, any[]> = {}
entries.forEach(([id, prices]) => {
if (id) map[id as string] = prices as any[]
})
agreementPricesMap.value = map
} catch {
invoiceList.value = []
mailList.value = []
agreementList.value = []
agreementPricesMap.value = {}
} finally {
loading.value = false
}
}
function scrollToSection(key: string) {
activeTab.value = key
nextTick(() => {
document
.getElementById(`view-section-${key}`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function handleClosed() {
invoiceList.value = []
mailList.value = []
agreementList.value = []
agreementPricesMap.value = {}
accountData.value = null
productQuotas.value = []
accountLoaded.value = false
activeTab.value = 'basic'
}
watch(
() => [props.modelValue, props.channelId] as const,
([visible, id]) => {
if (visible && id) {
loadSubTables()
loadAccountData()
}
}
)
</script>
<style scoped lang="scss">
.channel-view-body {
overflow: visible;
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin-bottom: 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #059669;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #059669;
}
}
/* KPI 摘要条(对齐 ProtocolViewDialog:统一薄荷绿通栏 + 图标徽章) */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 24px;
padding: 6px;
background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 52%, #ecfdf5 100%);
border: 1px solid #a7f3d0;
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
.kpi-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
background: transparent;
border-radius: 8px;
transition: background 0.2s;
&:not(:last-child) {
border-right: 1px solid rgba(167, 243, 208, 0.75);
}
&:hover {
background: rgba(255, 255, 255, 0.55);
}
}
.kpi-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: #fff;
border: 1px solid #a7f3d0;
color: #059669;
font-size: 18px;
}
.kpi-card__main {
min-width: 0;
flex: 1;
}
.kpi-label {
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
color: #64748b;
}
.kpi-value {
font-size: 15px;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 6px;
&--emphasis {
font-size: 16px;
font-weight: 700;
color: #047857;
letter-spacing: 0.02em;
}
}
.section-card {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--white {
background: #fff;
}
&--green {
background: #f0fdf4;
}
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
width: 3px;
height: 18px;
background: #10b981;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
}
.data-grid {
display: grid;
gap: 14px 20px;
&--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.data-item {
min-width: 0;
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 3px;
}
.data-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--muted {
color: #64748b;
font-weight: 400;
}
&--price {
color: #059669;
font-weight: 700;
}
}
.attachment-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 8px;
}
.remark-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
.sub-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.sub-card {
background: #fff;
border-radius: 10px;
padding: 16px 18px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.03);
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
}
}
.sub-card-header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agreement-cards {
display: flex;
flex-direction: column;
gap: 14px;
}
.agreement-card {
background: #fff;
border-radius: 10px;
border: 1px solid #e2e8f0;
overflow: hidden;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: #a7f3d0;
box-shadow: 0 2px 12px rgba(16, 185, 129, 0.1);
}
}
.agreement-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.agreement-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.agreement-card__info {
padding: 14px 18px;
}
.agreement-card__products {
padding: 14px 18px 16px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
}
.agreement-products-head {
margin-bottom: 10px;
}
.agreement-products-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background: #10b981;
border-radius: 2px;
margin-right: 8px;
vertical-align: -2px;
}
}
.agreement-price-list-wrap {
margin-bottom: 0;
border-radius: 8px;
background: #fff;
}
.agreement-price-empty {
padding: 20px 0;
}
.agreement-price-table.list-table {
border: none !important;
box-shadow: none !important;
&::before,
&::after {
display: none !important;
}
:deep(.el-table__inner-wrapper) {
border: none !important;
&::before {
display: none !important;
}
}
:deep(.el-table__cell) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td.el-table__cell) {
border-bottom: none !important;
}
}
.preview-container {
text-align: center;
}
/* 账户信息 */
.account-balance-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 22px;
background: linear-gradient(135deg, #ecfdf5 0%, #f0fdf4 100%);
border: 1px solid #a7f3d0;
border-radius: 10px;
margin-bottom: 20px;
}
.account-balance-card__label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.account-balance-card__value {
font-size: 26px;
font-weight: 700;
color: #059669;
letter-spacing: 0.02em;
}
.account-quotas-head {
margin-bottom: 10px;
}
.account-quotas-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background: #10b981;
border-radius: 2px;
margin-right: 8px;
vertical-align: -2px;
}
}
.account-quota-list-wrap {
margin-bottom: 0;
border-radius: 8px;
background: #fff;
}
.account-quota-table.list-table {
border: none !important;
box-shadow: none !important;
&::before,
&::after {
display: none !important;
}
:deep(.el-table__inner-wrapper) {
border: none !important;
&::before {
display: none !important;
}
}
:deep(.el-table__cell) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td.el-table__cell) {
border-bottom: none !important;
}
}
@media (max-width: 1200px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.kpi-strip .kpi-card {
border-right: none !important;
border-bottom: 1px solid rgba(167, 243, 208, 0.75);
&:nth-last-child(-n + 2) {
border-bottom: none;
}
}
.data-grid--3,
.data-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sub-cards {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.kpi-strip .kpi-card:nth-last-child(-n + 2) {
border-bottom: 1px solid rgba(167, 243, 208, 0.75);
}
.kpi-strip .kpi-card:last-child {
border-bottom: none;
}
}
</style>