This commit is contained in:
zlwu committed 2026-05-28 13:05:46 +08:00
1 parent f1f5d24eff
commit 3c7a47981a
138 files changed
+18654 -12092

No files matched your search

@@ -0,0 +1,208 @@
<!-- 临床应用缴费/退费提交信息只读展示 -->
<template>
<div class="clinical-payment-submit-readonly">
<h4 v-if="title" class="detail-block-title">{{ title }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>申请单号</dt>
<dd>{{ dash(detail?.applicationNo) }}</dd>
</div>
<div class="view-kv">
<dt>患者姓名</dt>
<dd>{{ dash(detail?.patientName) }}</dd>
</div>
<div class="view-kv">
<dt>渠道名称</dt>
<dd>{{ dash(detail?.channelName) }}</dd>
</div>
<div class="view-kv">
<dt>细胞类型/规格</dt>
<dd>{{ dash(detail?.cellTypeSpec) }}</dd>
</div>
<div class="view-kv">
<dt>输注时间</dt>
<dd>{{ dash(detail?.infusionTime) }}</dd>
</div>
<div v-if="showPayStatus" class="view-kv">
<dt>缴费状态</dt>
<dd>
<el-tag :type="payStatusTagType(String(detail?.payStatus || ''))" size="small">
{{ payStatusLabel(String(detail?.payStatus || '')) }}
</el-tag>
</dd>
</div>
<template v-if="mode === 'refund'">
<div class="view-kv">
<dt>缴费金额(元)</dt>
<dd>{{ formatAmount(detail?.paymentAmount ?? detail?.amount) }}</dd>
</div>
</template>
<template v-if="mode === 'receipt' || mode === 'full'">
<div class="view-kv">
<dt>缴费金额(元)</dt>
<dd>{{ formatAmount(detail?.paymentAmount ?? detail?.amount) }}</dd>
</div>
<div v-if="detail?.remark" class="view-kv view-kv-span-2">
<dt>备注</dt>
<dd>{{ dash(detail?.remark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 clinical-payment-submit-readonly__attachments">
<div class="view-field-block__label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="receiptAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-if="mode === 'refund' || (mode === 'full' && hasRefundInfo)">
<div class="view-kv">
<dt>退费金额(元)</dt>
<dd>{{ formatAmount(detail?.refundAmount) }}</dd>
</div>
<div v-if="detail?.refundRemark" class="view-kv view-kv-span-2">
<dt>退费备注</dt>
<dd>{{ dash(detail?.refundRemark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 clinical-payment-submit-readonly__attachments">
<div class="view-field-block__label">退费附件</div>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
</dl>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { payStatusLabel, payStatusTagType } from '@/utils/status-tag-registry'
const props = withDefaults(
defineProps<{
detail?: Record<string, unknown> | null
title?: string
mode?: 'receipt' | 'refund' | 'full'
showPayStatus?: boolean
}>(),
{ mode: 'receipt', showPayStatus: false }
)
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function formatAmount(v: unknown) {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function urlsToFiles(urlField: unknown) {
const urls = String(urlField || '')
if (!urls.trim()) 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: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
const receiptAttachmentFiles = computed(() => urlsToFiles(props.detail?.transferAttachments))
const refundAttachmentFiles = computed(() => urlsToFiles(props.detail?.refundAttachments))
const hasRefundInfo = computed(() => {
const d = props.detail
if (!d) return false
return (
d.refundAmount != null ||
Boolean(d.refundRemark) ||
Boolean(String(d.refundAttachments || '').trim())
)
})
</script>
<style scoped lang="scss">
.clinical-payment-submit-readonly {
width: 100%;
}
.detail-block-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0;
}
.view-kv-grid--4 {
@media (min-width: 720px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.view-kv {
min-width: 0;
dt {
margin: 0 0 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
}
dd {
margin: 0;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
}
}
.view-kv-span-2 {
grid-column: 1 / -1;
}
.view-field-block__label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.clinical-payment-submit-readonly__attachments {
margin-top: 4px;
}
.form-section-divider--inner {
height: 1px;
margin: 16px 0;
background: var(--mint-border, #e2e8f0);
}
</style>
@@ -0,0 +1,729 @@
<!-- 临床应用 · 输注计划缴费明细(查看态,对齐 ProtocolViewDialog 缴费明细) -->
<template>
<div
class="payment-detail-card"
:class="{
'payment-detail-card--refunded': payStatus === 'ytf',
'payment-detail-card--refund-pending': payStatus === 'tfqrz'
}"
>
<div class="pdc-header">
<div class="pdc-header-left" :class="payDetailColorClass(payStatus)">
缴费明细 ·
<span class="pdc-header-amount" :class="payDetailColorClass(payStatus)">
{{ fmtMoney(planAmount) }}
</span>
</div>
<el-tag :type="payStatusTagType(payStatus)" size="small">
{{ payStatusLabel(payStatus) }}
</el-tag>
</div>
<div class="pdc-info-row">
<div class="pdc-info-item">
<span class="info-label">应缴费金额(元)</span>
<span class="info-value">{{ fmtMoney(planAmount) }}</span>
</div>
<div class="pdc-info-item">
<span class="info-label">实缴金额(元)</span>
<span class="info-value">{{ fmtMoney(payment?.transferAmount ?? planAmount) }}</span>
</div>
<div v-if="payment?.remark" class="pdc-info-item">
<span class="info-label">备注</span>
<span class="info-value">{{ dash(payment?.remark) }}</span>
</div>
<div v-if="payStatus !== 'dfj' && payment?.updateTime" class="pdc-info-item">
<span class="info-label">提交时间</span>
<span class="info-value">{{ dash(payment?.updateTime) }}</span>
</div>
</div>
<div v-if="transferAttachmentFiles.length" class="pdc-attachments-row">
<div class="data-label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
<template v-if="flowSteps.length">
<div class="flow-section">
<div class="mini-flow">
<template v-for="(step, sIdx) in flowSteps" :key="`c-${step.key}`">
<div
v-if="step.connectorToNext"
class="mini-flow-connector"
:class="step.connectorToNext"
:style="miniFlowConnectorStyle(flowSteps.length, sIdx)"
/>
</template>
<div v-for="step in flowSteps" :key="step.key" class="mini-flow-step">
<div class="mini-flow-dot" :class="step.dotClass">{{ step.dotIcon }}</div>
<div class="mini-flow-label" :class="step.labelClass">{{ step.label }}</div>
</div>
</div>
<div class="flow-detail-cards">
<template v-for="step in flowSteps" :key="`d-${step.key}`">
<div
v-if="step.hasDetailCard"
class="flow-detail-card"
:class="'flow-detail-card--' + step.dotClass"
>
<div class="flow-detail-card__header">
<span class="flow-detail-card__dot" :class="step.dotClass">{{
step.dotIcon
}}</span>
<span class="flow-detail-card__title" :class="step.labelClass">{{
step.label
}}</span>
</div>
<div class="flow-detail-card__body">
<template v-if="step.key === 'pay'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag type="success" size="small">已提交</el-tag>
</span>
</div>
<div v-if="payment?.remark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">备注</span>
<span class="flow-detail-card__value">{{ payment.remark }}</span>
</div>
<div v-if="transferAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">转账凭证</span>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'receipt-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag v-if="isReceiptFinanceConfirmed" type="success" size="small"
>已确认</el-tag
>
<el-tag v-else type="warning" size="small">待确认</el-tag>
</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && receiptFinanceTime"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{ receiptFinanceTime }}</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && payment?.financeRemark"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{ payment.financeRemark }}</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && financeAttachmentFiles.length"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="financeAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-apply'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费金额(元)</span>
<span class="flow-detail-card__value" :class="payDetailColorClass(payStatus)">{{
fmtMoney(payment?.refundAmount)
}}</span>
</div>
<div v-if="payment?.refundRemark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费备注</span>
<span class="flow-detail-card__value">{{ payment.refundRemark }}</span>
</div>
<div v-if="refundAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费附件</span>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag type="danger" size="small">已退费</el-tag>
</span>
</div>
<div v-if="refundFinanceTime" class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{ refundFinanceTime }}</span>
</div>
<div v-if="payment?.refundFinanceRemark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{ payment.refundFinanceRemark }}</span>
</div>
<div v-if="refundFinanceAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="refundFinanceAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { payStatusLabel, payStatusTagType } from '@/utils/status-tag-registry'
const props = defineProps<{
payment?: Record<string, any> | null
}>()
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
interface MiniFlowStep {
key: string
label: string
dotClass: string
dotIcon: string
labelClass: string
connectorToNext?: string
hasDetailCard?: boolean
}
const payStatus = computed(() => String(props.payment?.payStatus || 'dfj'))
const planAmount = computed(
() => props.payment?.paymentAmount ?? props.payment?.amount ?? null
)
const isReceiptFinanceConfirmed = computed(() => {
const s = payStatus.value
return s !== 'jfqrz' && s !== 'dfj'
})
const receiptFinanceTime = computed(
() => props.payment?.financeConfirmTime || props.payment?.updateTime || ''
)
const refundFinanceTime = computed(
() => props.payment?.refundFinanceConfirmTime || props.payment?.updateTime || ''
)
const transferAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.transferAttachments || ''))
)
const financeAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.financeConfirmAttachments || ''))
)
const refundAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.refundAttachments || ''))
)
const refundFinanceAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.refundFinanceConfirmAttachments || ''))
)
const flowSteps = computed(() => buildMiniFlowSteps(props.payment || {}))
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function fmtMoney(v: unknown) {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function urlsToAttachmentFiles(urls: string) {
if (!urls.trim()) 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: `url_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
function payDetailColorClass(status: string) {
if (status === 'ytf') return 'red'
if (status === 'tfqrz') return 'amber'
return 'green'
}
function buildMiniFlowSteps(detail: Record<string, any>): MiniFlowStep[] {
const s = String(detail.payStatus || '')
if (s === 'dfj') return []
const steps: MiniFlowStep[] = []
steps.push({
key: 'pay',
label: '缴费确认',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: 'done',
hasDetailCard: true
})
if (s === 'jfqrz') {
steps.push({
key: 'receipt-finance',
label: '财务确认',
dotClass: 'current-amber',
dotIcon: '●',
labelClass: 'current-amber',
hasDetailCard: true
})
} else {
steps.push({
key: 'receipt-finance',
label: '财务确认',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: s === 'yjf' ? undefined : 'done',
hasDetailCard: true
})
}
if (s === 'tfqrz' || s === 'ytf') {
steps.push({
key: 'refund-apply',
label: '退费申请',
dotClass: s === 'tfqrz' ? 'current-amber' : 'done',
dotIcon: s === 'tfqrz' ? '●' : '✓',
labelClass: s === 'tfqrz' ? 'current-amber' : 'done',
connectorToNext: s === 'tfqrz' ? 'pending-gray' : 'done-red',
hasDetailCard: true
})
}
if (s === 'tfqrz') {
steps.push({
key: 'refund-finance',
label: '退费确认',
dotClass: 'pending-gray',
dotIcon: '○',
labelClass: 'pending-gray',
hasDetailCard: false
})
} else if (s === 'ytf') {
steps.push({
key: 'refund-finance',
label: '退费确认',
dotClass: 'done-red',
dotIcon: '✓',
labelClass: 'done-red',
hasDetailCard: true
})
}
if (steps.length) {
delete steps[steps.length - 1].connectorToNext
}
return steps
}
function miniFlowConnectorStyle(total: number, index: number) {
const leftPct = ((index + 0.5) / total) * 100
const widthPct = 100 / total
return `left: calc(${leftPct}% + 12px); width: calc(${widthPct}% - 12px);`
}
</script>
<style scoped lang="scss">
.payment-detail-card {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
&--refunded {
border-left: 3px solid #dc2626;
border-color: #e2e8f0;
border-left-color: #dc2626;
}
&--refund-pending {
border-left: 3px solid #d97706;
border-color: #e2e8f0;
border-left-color: #d97706;
}
}
.pdc-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pdc-header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
&::before {
content: '';
width: 2px;
height: 14px;
border-radius: 1px;
flex-shrink: 0;
}
&.green::before {
background: #10b981;
}
&.red::before {
background: #dc2626;
}
&.amber::before {
background: #d97706;
}
}
.pdc-header-amount {
font-size: 14px;
font-weight: 700;
margin-left: 4px;
&.green {
color: #059669;
}
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
}
.pdc-info-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 20px;
margin-bottom: 12px;
}
.pdc-info-item {
display: flex;
flex-direction: column;
gap: 2px;
.info-label {
font-size: 11px;
color: #94a3b8;
}
.info-value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
}
}
.pdc-attachments-row {
margin-bottom: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
.data-label {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
}
.flow-section {
margin-top: 12px;
}
.mini-flow {
display: flex;
align-items: center;
gap: 0;
margin: 12px 0;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
position: relative;
}
.mini-flow-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
z-index: 1;
}
.mini-flow-dot {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 1;
&.done {
background: #10b981;
color: #fff;
box-shadow: 0 0 0 3px rgb(16 185 129 / 15%);
}
&.done-red {
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 3px rgb(220 38 38 / 15%);
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
box-shadow: 0 0 0 3px rgb(217 119 6 / 15%);
}
&.pending-gray {
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
}
.mini-flow-label {
font-size: 10px;
margin-top: 6px;
text-align: center;
max-width: 80px;
line-height: 1.3;
&.done {
color: #059669;
font-weight: 600;
}
&.done-red {
color: #dc2626;
font-weight: 600;
}
&.current-amber {
color: #d97706;
font-weight: 600;
}
&.pending-gray {
color: #94a3b8;
}
}
.mini-flow-connector {
position: absolute;
top: 22px;
height: 2px;
z-index: 0;
&.done {
background: #10b981;
}
&.done-red {
background: #dc2626;
}
&.pending-gray {
background: #e2e8f0;
}
}
.flow-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 14px;
}
.flow-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
&--current-amber {
border-color: #fde68a;
background: #fffbeb;
}
&--done-red {
border-left: 3px solid #dc2626;
background: #fff;
}
&--pending-gray {
border-style: dashed;
background: #f8fafc;
}
}
.flow-detail-card__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
.flow-detail-card--done-red & {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.flow-detail-card--current-amber & {
background: #fffbeb;
border-bottom-color: #fde68a;
}
}
.flow-detail-card__dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
&.done {
background: #10b981;
color: #fff;
}
&.done-red {
background: #dc2626;
color: #fff;
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
}
&.pending-gray {
background: #f1f5f9;
border: 2px solid #e2e8f0;
color: #94a3b8;
}
}
.flow-detail-card__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
&.done {
color: #059669;
}
&.done-red {
color: #dc2626;
}
&.current-amber {
color: #d97706;
}
}
.flow-detail-card__body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.flow-detail-card__kv {
display: flex;
flex-direction: column;
gap: 2px;
}
.flow-detail-card__label {
font-size: 11px;
color: #94a3b8;
}
.flow-detail-card__value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
&.green {
color: #059669;
}
}
@media (max-width: 768px) {
.pdc-info-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>