Files
zkxw/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue
T
2026-05-18 16:21:38 +08:00

816 lines
23 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 v-loading="loading" class="channel-view-body">
<template v-if="channel">
<div 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-label">渠道名称</div>
<div class="kpi-value">{{ dash(channel.channelName) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">渠道类型</div>
<div class="kpi-value">
<span class="badge" :class="channelTypeBadgeClass(channel.channelType)">
{{ channelTypeLabel(channel.channelType) }}
</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">联系人</div>
<div class="kpi-value">{{ dash(channel.contactName) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">创建时间</div>
<div class="kpi-value">{{ dash(channel.createTime) }}</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">
<span class="badge" :class="channelTypeBadgeClass(channel.channelType)">
{{ channelTypeLabel(channel.channelType) }}
</span>
</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-agreement" class="section-card section-card--gray">
<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">
<span class="badge" :class="agreementTypeBadgeClass(agr.agreementType)">
{{ agreementTypeLabel(agr.agreementType) }}
</span>
<span class="badge" :class="agreementStatusBadgeClass(agr.status)">
{{ agreementStatusLabel(agr.status) }}
</span>
</div>
</div>
<div class="data-grid data-grid--4">
<div v-if="agr.settlementType" class="data-item">
<div class="data-label">结算方式</div>
<div class="data-value">
<span class="badge" :class="settlementTypeBadgeClass(agr.settlementType)">
{{ settlementTypeLabel(agr.settlementType) }}
</span>
</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>
</div>
<el-empty v-else description="暂无协议信息" :image-size="48" />
</div>
</template>
</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 { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import {
fetchChannelInvoices,
fetchChannelMailAddresses,
fetchChannelAgreements
} 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: '协议信息' }
] as const
const channelTypeMap: Record<string, string> = {
yy: '医院',
hzjg: '合作机构',
zs: '诊所',
zfjg: '政府机构',
qt: '其他'
}
const agreementTypeMap: Record<string, string> = {
kyxy: '框架协议',
zsxy: '中试协议',
lcxy: '临床协议'
}
const settlementTypeMap: Record<string, string> = {
yj: '月结',
jj: '季结',
nj: '年结',
dcjs: '单次结算'
}
const agreementStatusMap: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
yzf: '已作废'
}
const loading = ref(false)
const activeTab = ref('basic')
const invoiceList = ref<any[]>([])
const mailList = ref<any[]>([])
const agreementList = ref<any[]>([])
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 urls = String(channel.value?.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 channelTypeLabel(type: string) {
const key = String(type || '').trim()
if (!key) return '—'
return channelTypeMap[key] || key
}
function channelTypeBadgeClass(type: string): string {
const m: Record<string, string> = {
yy: 'badge--blue',
hzjg: 'badge--green',
zs: 'badge--amber',
zfjg: 'badge--red',
qt: 'badge--gray'
}
return m[String(type || '')] || 'badge--gray'
}
function agreementTypeLabel(type: string) {
return agreementTypeMap[String(type || '')] || dash(type)
}
function agreementTypeBadgeClass(type: string): string {
const m: Record<string, string> = {
kyxy: 'badge--blue',
zsxy: 'badge--green',
lcxy: 'badge--amber'
}
return m[String(type || '')] || 'badge--gray'
}
function settlementTypeLabel(type: string) {
return settlementTypeMap[String(type || '')] || dash(type)
}
function settlementTypeBadgeClass(type: string): string {
const m: Record<string, string> = {
yj: 'badge--gray',
jj: 'badge--green',
nj: 'badge--amber',
dcjs: 'badge--red'
}
return m[String(type || '')] || 'badge--gray'
}
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 agreementStatusLabel(status: string) {
return agreementStatusMap[String(status || '')] || dash(status)
}
function agreementStatusBadgeClass(status: string): string {
const m: Record<string, string> = {
dsx: 'badge--amber',
sxz: 'badge--green',
yzf: 'badge--red'
}
return m[String(status || '')] || 'badge--gray'
}
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>) {
const url = fullFileUrl(file.fileUrl)
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = file.fileName || ''
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
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))
} catch {
invoiceList.value = []
mailList.value = []
agreementList.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 = []
activeTab.value = 'basic'
}
watch(
() => [props.modelValue, props.channelId] as const,
([visible, id]) => {
if (visible && id) loadSubTables()
}
)
</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-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.kpi-card {
background: #fff;
border-radius: 12px;
padding: 18px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border-left: 3px solid #10b981;
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1);
}
}
.kpi-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.kpi-value {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 6px;
}
.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;
}
&--gray {
background: #f8fafc;
}
}
.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;
}
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
line-height: 1.6;
&--green {
background: #ecfdf5;
color: #059669;
}
&--amber {
background: #fffbeb;
color: #d97706;
}
&--gray {
background: #f1f5f9;
color: #64748b;
}
&--red {
background: #fef2f2;
color: #dc2626;
}
&--blue {
background: #eff6ff;
color: #2563eb;
}
}
.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 .data-grid {
padding: 14px 18px;
}
.preview-container {
text-align: center;
}
@media (max-width: 1200px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.data-grid--3,
.data-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sub-cards {
grid-template-columns: 1fr;
}
}
</style>