feat(web-admin): transfer remark columns and jfqrz withdraw UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-10 12:19:46 +08:00
1 parent c860e39363
commit 8cbbd3548b
8 files changed
+4774 -2650

No files matched your search

+18
View File
@@ -78,6 +78,13 @@ export function fetchSubmitPresignTransferFinance(transferId: string) {
})
}
export function fetchWithdrawPresignTransfer(transferId: string) {
return request.post({
url: `/web/admin/presign-transfers/${transferId}/withdraw`,
showSuccessMessage: true
})
}
export function fetchPresignTransferReceiptConfirmList(params?: {
page?: number
pageSize?: number
@@ -89,6 +96,17 @@ export function fetchPresignTransferReceiptConfirmList(params?: {
})
}
export function fetchPresignTransferReceiptConfirmedList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/presign-transfers/receipt-confirmed',
params
})
}
export function fetchConfirmPresignTransfer(
transferId: string,
data?: { financeConfirmAttachments?: string; financeRemark?: string }
@@ -48,6 +48,13 @@ export function fetchSubmitPaymentTransferFinance(transferId: string) {
})
}
export function fetchWithdrawPaymentTransfer(transferId: string) {
return request.post({
url: `/web/admin/payment-transfers/${transferId}/withdraw`,
showSuccessMessage: true
})
}
export function fetchPaymentTransferList(params?: {
page?: number
pageSize?: number
@@ -156,3 +163,17 @@ export function fetchSamplePaymentDetail(samplePaymentId: string) {
url: `/web/admin/sample-payments/${samplePaymentId}`
})
}
export function fetchPaymentChaseList(params?: {
page?: number
pageSize?: number
keyword?: string
chaseType?: string
payMode?: string
salesUserId?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/payment-chase',
params
})
}
@@ -9,13 +9,26 @@
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': mainTab === 'payment' }"
:aria-selected="mainTab === 'payment'"
@click="mainTab = 'payment'"
:class="{ 'is-active': mainTab === 'presign' }"
:aria-selected="mainTab === 'presign'"
@click="mainTab = 'presign'"
>
缴费确认
<span v-if="paymentPendingBadge > 0" class="panel-pill-tabs__badge">{{
formatTabBadgeCount(paymentPendingBadge)
意向金
<span v-if="presignPendingBadge > 0" class="panel-pill-tabs__badge">{{
formatTabBadgeCount(presignPendingBadge)
}}</span>
</button>
<button
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': mainTab === 'formal' }"
:aria-selected="mainTab === 'formal'"
@click="mainTab = 'formal'"
>
正式缴费
<span v-if="formalPendingBadge > 0" class="panel-pill-tabs__badge">{{
formatTabBadgeCount(formalPendingBadge)
}}</span>
</button>
<!-- TODO(P1): 开票改挂 transfer;当前仍走旧 payment_detail 开票 API -->
@@ -35,7 +48,7 @@
</div>
</div>
<div v-show="mainTab === 'payment'" class="panel-entity-body">
<div v-show="isPaymentTab" class="panel-entity-body">
<div class="finance-section-head finance-section-head--pending">
<h3 class="finance-section-title">
待确认
@@ -49,19 +62,20 @@
<el-form
:model="pendingSearchForm"
class="filter-form"
label-position="top"
@submit.prevent
>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="pendingSearchForm.keyword"
placeholder="协议号/样本编码/客户姓名/身份证号"
:placeholder="
isPresignTab ? '协议号/客户姓名' : '协议号/样本编码/客户姓名/身份证号'
"
clearable
@keyup.enter="handlePendingSearch"
/>
</el-form-item>
<el-form-item label="应缴费时间">
<el-form-item v-if="!isPresignTab" label="应缴费时间">
<el-date-picker
v-model="pendingInstallmentDueDateRange"
type="daterange"
@@ -92,50 +106,70 @@
<div class="pending-card__head">
<div class="pending-card__identity">
<div class="pending-card__name">{{ row.motherName || '-' }}</div>
<div class="pending-card__sub">
{{ row.protocolNo || '-' }}
<template v-if="row.sampleCode"> · {{ row.sampleCode }}</template>
<template v-if="row.motherIdCard">
· {{ maskIdCard(row.motherIdCard) }}
</template>
</div>
<div v-if="cardSubtitle(row)" class="pending-card__sub">{{ cardSubtitle(row) }}</div>
</div>
<div class="pending-card__amount">
<div class="pending-card__amount-val">
{{ formatPaymentAmount(row.transferAmount) }}
</div>
<div class="pending-card__amount-label">转账金额</div>
<div class="pending-card__amount-label">
{{ isPresignRow(row) ? '待确认意向金' : '待确认转账' }}
</div>
</div>
</div>
<div class="pending-card__grid">
<div class="pending-card__field">
<label>期次</label>
<span>第 {{ row.installmentNo ?? '-' }} 期</span>
</div>
<div class="pending-card__field">
<label>应缴费时间</label>
<span>{{ formatPaymentDueDate(row.installmentDueDate) }}</span>
</div>
<div class="pending-card__field">
<label>批次应缴</label>
<span>{{ formatPaymentAmount(row.dueAmount) }}</span>
</div>
<div class="pending-card__field">
<label>缴费方式</label>
<span>{{ payModeLabel(String(row.payMode || '')) }}</span>
</div>
<div class="pending-card__field">
<label>转账方式</label>
<span>{{ transferTypeLabel(String(row.transferType || '')) }}</span>
</div>
<div class="pending-card__field">
<label>转账时间</label>
<span>{{ formatDisplayOrDash(row.transferTime) }}</span>
</div>
<div class="pending-card__field">
<label>来源</label>
<span>{{ row.source === 'presign' ? '预签约意向金' : '正式协议' }}</span>
</div>
<div class="pending-card__transfer">
<span class="pending-card__transfer-way">{{
transferTypeLabel(String(row.transferType || '')) || '-'
}}</span>
<span class="pending-card__transfer-time">{{
formatDisplayOrDash(row.transferTime)
}}</span>
</div>
<div v-if="row.remark" class="pending-card__remark">备注:{{ row.remark }}</div>
<div class="pending-card__facts">
<template v-if="isPresignRow(row)">
<div class="pending-card__field">
<label>业务日期</label>
<span>{{ formatPaymentDueDate(row.orderDate) }}</span>
</div>
<div class="pending-card__field">
<label>预产期</label>
<span>{{ formatPaymentDueDate(row.expectedDeliveryDate) }}</span>
</div>
<div class="pending-card__field">
<label>合同金额</label>
<span>{{ formatPaymentAmount(row.contractAmount) }}</span>
<em v-if="row.contractPayMode">{{
payModeLabel(String(row.contractPayMode || ''))
}}</em>
</div>
<div class="pending-card__field">
<label>采集专员</label>
<span>{{ row.collectorUserName || '-' }}</span>
</div>
</template>
<template v-else>
<div class="pending-card__field">
<label>期次</label>
<span>第 {{ row.installmentNo ?? '-' }} 期</span>
</div>
<div class="pending-card__field">
<label>应缴费时间</label>
<span>{{ formatPaymentDueDate(row.installmentDueDate) }}</span>
</div>
<div class="pending-card__field">
<label>批次应缴</label>
<span>{{ formatPaymentAmount(row.dueAmount) }}</span>
</div>
<div class="pending-card__field">
<label>缴费方式</label>
<span>{{ payModeLabel(String(row.payMode || '')) }}</span>
</div>
</template>
</div>
<div v-if="isPresignRow(row) && row.sampleTypeNames" class="pending-card__samples">
<label>样本类型</label>
<p>{{ row.sampleTypeNames }}</p>
</div>
<div class="pending-card__foot">
<div class="pending-card__meta">
@@ -183,19 +217,20 @@
<el-form
:model="confirmedSearchForm"
class="filter-form"
label-position="top"
@submit.prevent
>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="confirmedSearchForm.keyword"
placeholder="协议号/样本编码/客户姓名/身份证号"
:placeholder="
isPresignTab ? '协议号/客户姓名' : '协议号/样本编码/客户姓名/身份证号'
"
clearable
@keyup.enter="handleConfirmedSearch"
/>
</el-form-item>
<el-form-item label="应缴费时间">
<el-form-item v-if="!isPresignTab" label="应缴费时间">
<el-date-picker
v-model="confirmedInstallmentDueDateRange"
type="daterange"
@@ -227,6 +262,7 @@
v-loading="confirmedLoading"
stripe
row-key="id"
:key="mainTab"
@row-dblclick="openViewConfirm"
>
<el-table-column
@@ -250,12 +286,15 @@
</el-table-column>
<el-table-column
prop="sampleCode"
label="样本编码"
min-width="120"
:label="isPresignTab ? '样本类型' : '样本编码'"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.sampleCode || '-' }}</template>
<template #default="{ row }">
{{ isPresignTab ? row.sampleTypeNames || '-' : row.sampleCode || '-' }}
</template>
</el-table-column>
<el-table-column
prop="motherName"
@@ -267,6 +306,7 @@
<template #default="{ row }">{{ row.motherName || '-' }}</template>
</el-table-column>
<el-table-column
v-if="!isPresignTab"
prop="motherIdCard"
label="身份证号"
min-width="170"
@@ -276,27 +316,22 @@
<template #default="{ row }">{{ maskIdCard(row.motherIdCard) }}</template>
</el-table-column>
<el-table-column
prop="source"
label="来源"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'presign' ? 'warning' : 'primary'">
{{ row.source === 'presign' ? '预签约' : '正式' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
:prop="isPresignTab ? 'orderStatus' : 'protocolStatus'"
:label="isPresignTab ? '订单状态' : '协议状态'"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="isPresignTab"
:type="presignOrderStatusTagType(String(row.orderStatus || ''))"
size="small"
>
{{ presignOrderStatusLabel(String(row.orderStatus || '')) }}
</el-tag>
<el-tag
v-else
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{
@@ -309,15 +344,17 @@
</template>
</el-table-column>
<el-table-column
v-if="!isPresignTab"
prop="payMode"
label="缴费方式"
min-width="100"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{ payModeLabel(String(row.payMode || '')) }}</template>
</el-table-column>
<el-table-column
v-if="!isPresignTab"
prop="installmentNo"
label="期数"
min-width="80"
@@ -327,29 +364,35 @@
<template #default="{ row }">{{ row.installmentNo ?? '-' }}</template>
</el-table-column>
<el-table-column
prop="installmentDueDate"
label="应缴费时间"
min-width="120"
:prop="isPresignTab ? 'orderDate' : 'installmentDueDate'"
:label="isPresignTab ? '业务日期' : '应缴费时间'"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">{{
formatPaymentDueDate(row.installmentDueDate)
isPresignTab
? formatPaymentDueDate(row.orderDate)
: formatPaymentDueDate(row.installmentDueDate)
}}</template>
</el-table-column>
<el-table-column
prop="dueAmount"
label="应缴金额(元)"
min-width="120"
:prop="isPresignTab ? 'contractAmount' : 'dueAmount'"
:label="isPresignTab ? '合同金额(元)' : '应缴金额(元)'"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatPaymentAmount(row.dueAmount) }}</template>
<template #default="{ row }">{{
isPresignTab
? formatPaymentAmount(row.contractAmount)
: formatPaymentAmount(row.dueAmount)
}}</template>
</el-table-column>
<el-table-column
prop="transferAmount"
label="实缴金额(元)"
min-width="120"
:label="isPresignTab ? '意向金(元)' : '实缴金额(元)'"
min-width="130"
align="center"
header-align="center"
>
@@ -375,6 +418,16 @@
>
<template #default="{ row }">{{ formatDisplayOrDash(row.transferTime) }}</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
<el-table-column
prop="payStatus"
label="状态"
@@ -383,7 +436,7 @@
header-align="center"
>
<template #default>
<el-tag type="success" size="small">已缴费</el-tag>
<el-tag type="success" size="small">缴费成功</el-tag>
</template>
</el-table-column>
<el-table-column
@@ -440,7 +493,9 @@
<el-dialog
v-model="dialogVisible"
title="确认收款"
:title="
isPresignRow(confirmRow) ? '确认收款(预签约意向金)' : '确认收款(正式协议缴费)'
"
width="900px"
class="dialog-form-layout payment-confirm-dialog"
align-center
@@ -490,7 +545,11 @@
<el-dialog
v-model="viewDialogVisible"
title="确认收款信息"
:title="
isPresignRow(viewRow)
? '确认收款信息(预签约意向金)'
: '确认收款信息(正式协议缴费)'
"
width="900px"
class="dialog-form-layout payment-confirm-dialog"
align-center
@@ -532,6 +591,7 @@
} from '@/api/protocol-sample-payment'
import {
fetchPresignTransferReceiptConfirmList,
fetchPresignTransferReceiptConfirmedList,
fetchConfirmPresignTransfer,
fetchRejectPresignTransfer
} from '@/api/presign-order'
@@ -551,14 +611,27 @@
loadTransferTypeDict,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
import {
presignOrderStatusLabel,
presignOrderStatusTagType
} from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
import InvoiceRequestTab from './InvoiceRequestTab.vue'
defineOptions({ name: 'CellStoragePaymentConfirm' })
const route = useRoute()
const mainTab = ref('payment')
const paymentPendingBadge = ref(0)
const mainTab = ref<'presign' | 'formal' | 'invoice'>(
String(route.query.tab || '') === 'invoice'
? 'invoice'
: String(route.query.tab || '') === 'formal' || String(route.query.tab || '') === 'payment'
? 'formal'
: 'presign'
)
const isPaymentTab = computed(() => mainTab.value === 'presign' || mainTab.value === 'formal')
const isPresignTab = computed(() => mainTab.value === 'presign')
const presignPendingBadge = ref(0)
const formalPendingBadge = ref(0)
const invoicePendingBadge = ref(0)
function formatTabBadgeCount(count: number): string {
@@ -570,6 +643,23 @@
return '¥' + formatMoneyDisplay(v)
}
function isPresignRow(row: Record<string, unknown> | null | undefined) {
return String(row?.source || '') === 'presign'
}
function cardSubtitle(row: Record<string, unknown>) {
const parts: string[] = []
const protocolNo = String(row.protocolNo || '').trim()
if (protocolNo) parts.push(protocolNo)
if (!isPresignRow(row)) {
const sampleCode = String(row.sampleCode || '').trim()
if (sampleCode) parts.push(sampleCode)
const idCard = String(row.motherIdCard || '').trim()
if (idCard) parts.push(maskIdCard(idCard))
}
return parts.join(' · ')
}
const pendingSearchForm = reactive({ keyword: '' })
const confirmedSearchForm = reactive({ keyword: '' })
const pendingInstallmentDueDateRange = ref<[string, string] | null>(null)
@@ -631,7 +721,8 @@
fetchPresignTransferReceiptConfirmList({ page: 1, pageSize: 1 }),
fetchProtocolInvoicePendingCount()
])
paymentPendingBadge.value = (paymentRes.total ?? 0) + (presignRes.total ?? 0)
formalPendingBadge.value = paymentRes.total ?? 0
presignPendingBadge.value = presignRes.total ?? 0
invoicePendingBadge.value = Number(invRes?.count ?? 0)
} catch {
// 角标加载失败时保留上次数值,避免干扰主流程
@@ -641,18 +732,19 @@
async function loadPending() {
pendingLoading.value = true
try {
// 正式 + 预签约意向金待办合并(前端 merge,分页近似:各取一页后合并)
const [formal, presign] = await Promise.all([
fetchPaymentTransferReceiptConfirmList(buildPendingParams()),
fetchPresignTransferReceiptConfirmList({
if (isPresignTab.value) {
const res = await fetchPresignTransferReceiptConfirmList({
page: pendingPagination.page,
pageSize: pendingPagination.pageSize,
keyword: pendingSearchForm.keyword || undefined
})
])
const merged = [...(formal.list || []), ...(presign.list || [])]
pendingData.value = merged
pendingPagination.total = (formal.total ?? 0) + (presign.total ?? 0)
pendingData.value = res.list || []
pendingPagination.total = res.total ?? 0
} else {
const res = await fetchPaymentTransferReceiptConfirmList(buildPendingParams())
pendingData.value = res.list || []
pendingPagination.total = res.total ?? 0
}
} finally {
pendingLoading.value = false
}
@@ -661,9 +753,19 @@
async function loadConfirmed() {
confirmedLoading.value = true
try {
const res = await fetchPaymentTransferReceiptConfirmedList(buildConfirmedParams())
confirmedData.value = res.list || []
confirmedPagination.total = res.total ?? 0
if (isPresignTab.value) {
const res = await fetchPresignTransferReceiptConfirmedList({
page: confirmedPagination.page,
pageSize: confirmedPagination.pageSize,
keyword: confirmedSearchForm.keyword || undefined
})
confirmedData.value = res.list || []
confirmedPagination.total = res.total ?? 0
} else {
const res = await fetchPaymentTransferReceiptConfirmedList(buildConfirmedParams())
confirmedData.value = res.list || []
confirmedPagination.total = res.total ?? 0
}
} finally {
confirmedLoading.value = false
}
@@ -738,7 +840,7 @@
async function submitReject() {
if (!currentDetailId.value) return
try {
await ElMessageBox.confirm('确定退回?退回后状态将变为「待缴费」。', '退回确认', {
await ElMessageBox.confirm('确定退回?退回后状态将变为「已缴费」。', '退回确认', {
type: 'warning'
})
} catch {
@@ -779,20 +881,18 @@
onMounted(async () => {
await loadTransferTypeDict()
const tab = route.query.tab
if (tab === 'invoice') {
mainTab.value = 'invoice'
} else {
mainTab.value = 'payment'
}
loadTabPendingCounts()
if (mainTab.value === 'payment') {
if (isPaymentTab.value) {
loadAll()
}
})
watch(mainTab, () => {
watch(mainTab, (tab) => {
loadTabPendingCounts()
if (tab === 'invoice') return
pendingPagination.page = 1
confirmedPagination.page = 1
loadAll()
})
</script>
@@ -818,8 +918,7 @@
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
padding: 0 0 4px;
line-height: 1.2;
padding-right: 12px;
}
.pending-card-section {
@@ -829,23 +928,26 @@
.pending-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
padding: 12px 24px 0;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
padding: 16px 24px 8px;
}
.pending-card {
display: flex;
flex-direction: column;
gap: 14px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
border-radius: 12px;
background: #fff;
padding: 16px 18px;
padding: 20px 22px 16px;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #c5e0d4;
box-shadow: 0 2px 8px rgb(15 23 42 / 5%);
border-color: #a7f3d0;
box-shadow: 0 4px 14px rgb(15 23 42 / 6%);
}
}
@@ -853,19 +955,18 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
gap: 16px;
}
.pending-card__name {
font-size: 15px;
font-size: 18px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
line-height: 1.3;
}
.pending-card__sub {
margin-top: 2px;
margin-top: 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
@@ -877,37 +978,93 @@
}
.pending-card__amount-val {
font-size: 18px;
font-size: 22px;
font-weight: 700;
color: var(--primary-dark, #059669);
font-variant-numeric: tabular-nums;
line-height: 1.3;
line-height: 1.2;
}
.pending-card__amount-label {
margin-top: 2px;
font-size: 11px;
margin-top: 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.pending-card__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
.pending-card__transfer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
margin-bottom: 14px;
padding: 10px 12px;
background: var(--primary-bg, #ecfdf5);
border-radius: 8px;
}
.pending-card__transfer-way {
font-size: 13px;
font-weight: 600;
color: var(--primary-dark, #059669);
}
.pending-card__transfer-time {
font-size: 13px;
color: var(--mint-text, #334155);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.pending-card__remark {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
word-break: break-all;
}
.pending-card__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 24px;
}
.pending-card__field label {
display: block;
margin-bottom: 2px;
font-size: 11px;
margin-bottom: 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.pending-card__field span {
font-size: 13px;
font-size: 14px;
color: var(--text, #1e293b);
line-height: 1.4;
word-break: break-word;
}
.pending-card__field em {
display: inline-block;
margin-left: 6px;
font-size: 12px;
font-style: normal;
color: var(--mint-text-muted, #64748b);
}
.pending-card__samples {
padding-top: 2px;
label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
p {
margin: 0;
font-size: 13px;
color: var(--mint-text, #334155);
line-height: 1.55;
}
}
.pending-card__foot {
@@ -915,6 +1072,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 2px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
@@ -984,8 +1142,8 @@
grid-template-columns: 1fr;
}
.pending-card__grid {
grid-template-columns: repeat(2, 1fr);
.pending-card__facts {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,743 @@
<!-- 缴费信息 · Excel 宽表(基础信息 + 第1~6期 + 汇总) -->
<template>
<div class="payment-excel-list">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="协议号/客户/样本编码/电话"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="缴费方式">
<el-select v-model="searchForm.payMode" placeholder="全部" clearable style="width: 100%">
<el-option label="趸交" value="dj" />
<el-option label="分期" value="fq" />
<el-option label="分改趸" value="fgd" />
<el-option label="趸交+分期" value="djfq" />
</el-select>
</el-form-item>
<el-form-item label="缴费进度">
<el-select
v-model="searchForm.progressStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option
v-for="opt in progressStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="应缴费时间">
<el-date-picker
v-model="dueDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap excel-scroll">
<el-table
class="list-table excel-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
border
>
<el-table-column
type="index"
label="序号"
min-width="64"
fixed="left"
align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column label="姓名" min-width="90" fixed="left" align="center">
<template #default="{ row }">{{ row.motherName || '—' }}</template>
</el-table-column>
<el-table-column label="手机号" min-width="120" align="center">
<template #default="{ row }">{{ row.motherPhone || '—' }}</template>
</el-table-column>
<el-table-column
prop="protocolNo"
label="合同编号"
min-width="130"
align="center"
show-overflow-tooltip
/>
<el-table-column label="签署日期" min-width="110" align="center">
<template #default="{ row }">{{ row.signDate || '—' }}</template>
</el-table-column>
<el-table-column label="存储年限" min-width="90" align="center">
<template #default="{ row }">
{{ row.storageYears != null && row.storageYears !== '' ? row.storageYears : '—' }}
</template>
</el-table-column>
<el-table-column label="存储项目" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.sampleTypeName || '—' }}</template>
</el-table-column>
<el-table-column label="合同总额" min-width="100" align="center">
<template #default="{ row }">{{ formatAmount(row.contractAmount) }}</template>
</el-table-column>
<el-table-column label="缴费方式" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="payModeTagType(String(row.payMode || ''))" size="small">
{{ payModeLabel(String(row.payMode || '')) || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠金额" min-width="100" align="center">
<template #default="{ row }">{{ formatAmount(row.discountAmount) }}</template>
</el-table-column>
<el-table-column label="应收总额" min-width="100" align="center">
<template #default="{ row }">{{ formatAmount(row.receivableAmount) }}</template>
</el-table-column>
<el-table-column
v-for="p in 6"
:key="'period-' + p"
:label="'第' + p + '期'"
align="center"
:class-name="'period-col period-col--' + p"
>
<el-table-column label="应缴费金额(元)" min-width="120" align="center">
<template #default="{ row }">{{ periodDue(row, p - 1) }}</template>
</el-table-column>
<el-table-column label="应缴费时间" min-width="120" align="center">
<template #default="{ row }">{{ periodField(row, p - 1, 'dueDate') }}</template>
</el-table-column>
<el-table-column label="已缴费金额" min-width="110" align="center">
<template #default="{ row }">
<button
v-if="periodHasPaid(row, p - 1)"
type="button"
class="amount-link"
@click="openPeriodDialog(row, p - 1)"
>
{{ periodPaid(row, p - 1) }}
</button>
<span v-else class="muted">{{ periodPaid(row, p - 1) }}</span>
</template>
</el-table-column>
<el-table-column label="已缴费时间" min-width="120" align="center">
<template #default="{ row }">{{ periodField(row, p - 1, 'payDate') }}</template>
</el-table-column>
<el-table-column label="欠缴费金额" min-width="110" align="center">
<template #default="{ row }">{{ periodArrears(row, p - 1) }}</template>
</el-table-column>
</el-table-column>
<el-table-column label="累计实缴" min-width="100" align="center">
<template #default="{ row }">
<strong class="amount-strong">{{ formatAmount(row.paidAmount) }}</strong>
</template>
</el-table-column>
<el-table-column label="待缴金额" min-width="100" align="center">
<template #default="{ row }">{{ formatAmount(row.dueRemainAmount) }}</template>
</el-table-column>
<el-table-column label="备注" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleViewProtocol(row)">
<el-icon><Document /></el-icon>协议单
</el-button>
<el-button
v-if="row.canConfirm"
class="btn-op-item primary"
@click="handleConfirm(row)"
>
<el-icon><Check /></el-icon>缴费确认
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="periodDialogVisible"
:title="periodDialogTitle"
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<div v-loading="periodLoading" class="period-dialog-body">
<div class="summary-grid">
<div class="summary-item">
<label>应缴</label>
<strong>{{ formatAmountYuan(periodSummary.dueAmount) }}</strong>
</div>
<div class="summary-item">
<label>实缴合计</label>
<strong class="amount-strong">{{ formatAmountYuan(periodSummary.paidAmount) }}</strong>
</div>
<div class="summary-item">
<label>欠缴</label>
<strong>{{ formatAmountYuan(periodSummary.arrearsAmount) }}</strong>
</div>
</div>
<div class="section-subtitle">转账明细</div>
<div v-if="!periodTransfers.length" class="muted">暂无转账笔</div>
<div v-for="(tr, idx) in periodTransfers" :key="String(tr.id || idx)" class="transfer-card">
<div class="transfer-card__main">
<div class="transfer-card__top">
<strong>第 {{ idx + 1 }} 笔</strong>
<el-tag :type="payStatusTagType(String(tr.payStatus || ''))" size="small">
{{ payStatusLabel(String(tr.payStatus || '')) || '—' }}
</el-tag>
<span class="amount-strong">{{ formatAmountYuan(tr.transferAmount) }}</span>
</div>
<div class="transfer-card__meta">
<span>{{ transferTypeLabel(String(tr.transferType || '')) || '—' }}</span>
<span>{{ formatDisplayOrDash(tr.transferTime) }}</span>
</div>
<div v-if="tr.remark" class="transfer-card__remark">备注:{{ tr.remark }}</div>
<div v-if="tr.financeRemark" class="transfer-card__remark"
>财务备注:{{ tr.financeRemark }}</div
>
</div>
<div class="transfer-card__attach">
<span class="transfer-card__attach-label">转账凭证</span>
<ProtocolAttachmentReadonly
v-if="transferAttachmentFiles(tr).length"
:files="transferAttachmentFiles(tr)"
size="compact"
@preview="handleAttachPreview"
@download="handleAttachDownload"
/>
<span v-else class="muted">暂无凭证</span>
</div>
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="periodDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
<el-button
v-if="periodCanConfirm"
class="btn-main"
type="primary"
:loading="confirming"
@click="openConfirmFromPeriod"
>
<el-icon><Check /></el-icon>缴费确认
</el-button>
</template>
</el-dialog>
<PaymentReceiptConfirmDialog
v-model="receiptDialogVisible"
:preview-row="receiptPreviewRow"
:is-transfer="true"
:target-id="receiptTargetId"
@saved="onReceiptRefresh"
@submitted="onReceiptRefresh"
/>
<el-dialog
v-model="attachPreviewVisible"
title="附件预览"
width="720px"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<img
v-if="attachPreviewUrl"
:src="attachPreviewUrl"
alt="附件预览"
class="attach-preview-img"
/>
</el-dialog>
<ProtocolViewDialog
v-model="protocolVisible"
:protocol-id="protocolViewId"
:list-row="protocolViewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</div>
</template>
<script setup lang="ts">
import { Search, RefreshLeft, Check, Document, Close } from '@element-plus/icons-vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import ProtocolAttachmentReadonly from '../protocol/ProtocolAttachmentReadonly.vue'
import PaymentReceiptConfirmDialog from '@/components/business/payment-receipt-confirm-dialog/index.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
fetchSamplePaymentList,
fetchSamplePaymentDetail,
fetchCreatePaymentTransfer
} from '@/api/protocol-sample-payment'
import { type CollectionProtocolRow } from '@/api/collection-protocol'
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
payModeLabel,
payModeTagType,
samplePaymentProgressLabel,
payStatusLabel,
payStatusTagType,
transferTypeLabel
} from '@/utils/status-tag-registry'
defineOptions({ name: 'CellStoragePaymentExcelList' })
type PeriodCell = {
scheduleId?: string
scheduleNo?: number
dueAmount?: number | null
dueDate?: string
displayPaidAmount?: number | null
arrearsAmount?: number | null
payDate?: string
payTypeSummary?: string
financeAudit?: string
} | null
const progressStatusOptions = [
{ value: 'dfj', label: samplePaymentProgressLabel('dfj') },
{ value: 'jfqrz', label: samplePaymentProgressLabel('jfqrz') },
{ value: 'bfyj', label: samplePaymentProgressLabel('bfyj') },
{ value: 'yjq', label: samplePaymentProgressLabel('yjq') }
]
const route = useRoute()
const searchForm = reactive({
keyword: '',
payMode: '',
progressStatus: ''
})
const dueDateRange = ref<[string, string] | null>(null)
const loading = ref(false)
const tableData = ref<Record<string, any>[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const periodDialogVisible = ref(false)
const periodLoading = ref(false)
const periodDialogTitle = ref('')
const periodSummary = reactive({
dueAmount: null as number | null,
paidAmount: null as number | null,
arrearsAmount: null as number | null
})
const periodTransfers = ref<Record<string, any>[]>([])
const periodCanConfirm = ref(false)
const periodSchedule = ref<Record<string, any> | null>(null)
const periodSamplePaymentId = ref('')
const confirming = ref(false)
const receiptDialogVisible = ref(false)
const receiptPreviewRow = ref<Record<string, any> | null>(null)
const receiptTargetId = ref('')
const protocolVisible = ref(false)
const protocolViewId = ref('')
const protocolViewRow = ref<CollectionProtocolRow | null>(null)
const attachPreviewVisible = ref(false)
const attachPreviewUrl = ref('')
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function formatAmount(v: unknown) {
if (v == null || v === '') return '—'
return formatMoneyDisplay(v)
}
function formatAmountYuan(v: unknown) {
if (v == null || v === '') return '—'
return `${formatMoneyDisplay(v)} 元`
}
function transferAttachmentFiles(tr: Record<string, any>) {
if (Array.isArray(tr.attachments) && tr.attachments.length) {
return tr.attachments
}
const urls = String(tr.transferAttachments || '')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
return urls.map((url, idx) => ({
id: `url_${idx}`,
fileName: url.split('/').pop() || `附件${idx + 1}`,
fileUrl: url
}))
}
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url) || url.startsWith('/')) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function handleAttachPreview(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
attachPreviewUrl.value = fullFileUrl(url)
attachPreviewVisible.value = true
}
function handleAttachDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl })
}
function periodsOf(row: Record<string, any>): PeriodCell[] {
const arr = (row.periods || []) as PeriodCell[]
const out: PeriodCell[] = []
for (let i = 0; i < 6; i++) out.push(arr[i] ?? null)
return out
}
function periodRaw(row: Record<string, any>, idx: number): PeriodCell {
return periodsOf(row)[idx]
}
function periodDue(row: Record<string, any>, idx: number) {
const p = periodRaw(row, idx)
return p ? formatAmount(p.dueAmount) : '—'
}
function periodPaid(row: Record<string, any>, idx: number) {
const p = periodRaw(row, idx)
return p ? formatAmount(p.displayPaidAmount) : '—'
}
function periodHasPaid(row: Record<string, any>, idx: number) {
const p = periodRaw(row, idx)
if (!p) return false
const n = Number(p.displayPaidAmount)
return Number.isFinite(n) && n > 0
}
function periodArrears(row: Record<string, any>, idx: number) {
const p = periodRaw(row, idx)
return p ? formatAmount(p.arrearsAmount) : '—'
}
function periodField(row: Record<string, any>, idx: number, key: keyof NonNullable<PeriodCell>) {
const p = periodRaw(row, idx)
if (!p) return '—'
const v = p[key]
return v != null && v !== '' ? String(v) : '—'
}
async function loadData() {
loading.value = true
try {
const res = await fetchSamplePaymentList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
payMode: searchForm.payMode || undefined,
progressStatus: searchForm.progressStatus || undefined,
installmentDueDateStart: dueDateRange.value?.[0] || undefined,
installmentDueDateEnd: dueDateRange.value?.[1] || undefined
})
tableData.value = (res.list || []) as Record<string, any>[]
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.payMode = ''
searchForm.progressStatus = ''
dueDateRange.value = null
handleSearch()
}
function handleViewProtocol(row: Record<string, any>) {
protocolViewId.value = String(row.collectionProtocolId || '')
protocolViewRow.value = {
id: row.collectionProtocolId,
protocolNo: row.protocolNo,
motherName: row.motherName,
motherPhone: row.motherPhone,
protocolStatus: row.protocolStatus,
voidType: row.voidType
} as CollectionProtocolRow
protocolVisible.value = true
}
async function openPeriodDialog(row: Record<string, any>, idx: number) {
const cell = periodRaw(row, idx)
if (!cell?.scheduleId) return
periodSamplePaymentId.value = String(row.id || '')
periodDialogTitle.value = `${row.motherName || ''} · ${row.protocolNo || ''} · 第 ${idx + 1} 期转账记录`
periodDialogVisible.value = true
periodLoading.value = true
periodTransfers.value = []
periodCanConfirm.value = false
periodSchedule.value = null
try {
const detail = await fetchSamplePaymentDetail(String(row.id))
const schedules = ((detail.schedules as any[]) || []) as Record<string, any>[]
const sch = schedules.find((s) => String(s.id) === String(cell.scheduleId))
periodSchedule.value = sch || null
periodSummary.dueAmount = sch?.dueAmount ?? cell.dueAmount ?? null
// 列表 displayPaidAmount / arrearsAmount 与详情 paidAmount 已对齐(含 dfj+jfqrz+yjf)
periodSummary.paidAmount =
sch?.paidAmount ?? cell.displayPaidAmount ?? null
periodSummary.arrearsAmount =
sch?.arrearsAmount ?? cell.arrearsAmount ?? null
periodTransfers.value = (sch?.transfers || []) as Record<string, any>[]
const hasJfqrz = periodTransfers.value.some((t) => String(t.payStatus) === 'jfqrz')
const arrears = Number(periodSummary.arrearsAmount)
periodCanConfirm.value = !hasJfqrz && Number.isFinite(arrears) && arrears > 0
} finally {
periodLoading.value = false
}
}
async function openConfirmFromPeriod() {
const schedule = periodSchedule.value
if (!schedule?.id) return
confirming.value = true
try {
const transfers = (schedule.transfers || []) as Record<string, any>[]
const dfj = transfers.find((t) => String(t.payStatus) === 'dfj')
let transferId = dfj?.id ? String(dfj.id) : ''
if (!transferId) {
const arrears = Number(schedule.arrearsAmount)
const due = Number(schedule.dueAmount)
const amount = arrears > 0 ? arrears : due
const created = await fetchCreatePaymentTransfer({
scheduleId: String(schedule.id),
transferAmount: amount
})
transferId = String((created as any)?.id || (created as any)?.data?.id || '')
if (!transferId) {
ElMessage.error('创建转账笔失败')
return
}
}
receiptTargetId.value = transferId
receiptPreviewRow.value = {
motherName: periodDialogTitle.value,
installmentNo: schedule.scheduleNo,
dueAmount: schedule.dueAmount,
installmentDueDate: schedule.dueDate,
scheduleId: schedule.id,
transferAmount: schedule.arrearsAmount || schedule.dueAmount
}
receiptDialogVisible.value = true
periodDialogVisible.value = false
} finally {
confirming.value = false
}
}
async function handleConfirm(row: Record<string, any>) {
const detail = await fetchSamplePaymentDetail(String(row.id))
const schedules = ((detail.schedules as any[]) || []) as Record<string, any>[]
const target = [...schedules]
.sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
.find((s) => {
const transfers = (s.transfers || []) as any[]
if (transfers.some((t) => String(t.payStatus) === 'jfqrz')) return false
return Number(s.arrearsAmount) > 0
})
if (!target) {
ElMessage.warning('暂无可确认的期次')
return
}
periodSamplePaymentId.value = String(row.id)
periodSchedule.value = target
periodDialogTitle.value = `${row.motherName || ''} · ${row.protocolNo || ''} · 第 ${target.scheduleNo} 期`
await openConfirmFromPeriod()
}
function onReceiptRefresh() {
loadData()
}
onMounted(() => {
const tab = String(route.query.tab || '')
if (['dfj', 'jfqrz', 'bfyj', 'yjq'].includes(tab)) {
searchForm.progressStatus = tab
}
loadData()
})
</script>
<style scoped lang="scss">
.excel-scroll {
overflow: auto;
}
.amount-link {
border: none;
background: none;
padding: 0;
color: var(--primary-dark, #059669);
font-weight: 600;
cursor: pointer;
border-bottom: 1px dashed rgba(5, 150, 105, 0.45);
}
.amount-strong {
color: var(--primary-dark, #059669);
}
.muted {
color: var(--mint-text-muted, #64748b);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.summary-item {
padding: 10px 12px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
background: #f8fafc;
label {
display: block;
font-size: 11px;
color: #64748b;
margin-bottom: 2px;
}
}
.section-subtitle {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
}
.transfer-card {
display: flex;
gap: 12px 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 8px 12px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
}
.transfer-card__main {
min-width: 0;
flex: 1;
}
.transfer-card__top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 4px;
}
.transfer-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
font-size: 13px;
color: #64748b;
}
.transfer-card__remark {
margin-top: 4px;
font-size: 12px;
color: #64748b;
word-break: break-all;
}
.transfer-card__attach {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
max-width: 52%;
}
.transfer-card__attach-label {
flex-shrink: 0;
font-size: 12px;
color: #64748b;
}
.attach-preview-img {
display: block;
max-width: 100%;
margin: 0 auto;
}
:deep(.period-col--1) {
background: #eff6ff;
}
:deep(.period-col--2) {
background: #fff7ed;
}
:deep(.period-col--3) {
background: #fefce8;
}
:deep(.period-col--4) {
background: #f0fdf4;
}
:deep(.period-col--5) {
background: #fff7ed;
}
:deep(.period-col--6) {
background: #ecfdf5;
}
</style>
@@ -4,17 +4,19 @@
<el-dialog
:model-value="modelValue"
:title="orderId ? '编辑预签约订单' : '新增预签约订单'"
width="920px"
align-center
destroy-on-close
width="1200px"
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<DialogFormBody v-loading="loading">
<el-form ref="formRef" :model="form" label-position="top" scroll-to-error>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="业务日期">
<el-date-picker
v-model="form.orderDate"
@@ -75,6 +77,52 @@
/>
</el-select>
</el-form-item>
<el-form-item label="采集专员">
<el-select
v-model="form.collectorUserId"
class="w-full"
filterable
remote
clearable
reserve-keyword
:remote-method="remoteSearchCollectorUsers"
:loading="collectorLoading"
:disabled="isConverted"
placeholder="输入姓名检索用户"
style="width: 100%"
@change="onCollectorUserChange"
>
<el-option
v-for="u in collectorUserOptions"
:key="u.id"
:label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
:value="u.id"
/>
</el-select>
</el-form-item>
<el-form-item label="采集经理">
<el-select
v-model="form.regionManagerUserId"
class="w-full"
filterable
remote
clearable
reserve-keyword
:remote-method="remoteSearchManagerUsers"
:loading="managerLoading"
:disabled="isConverted"
placeholder="默认取地区负责人,可修改"
style="width: 100%"
@change="onManagerUserChange"
>
<el-option
v-for="u in managerUserOptions"
:key="u.id"
:label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
:value="u.id"
/>
</el-select>
</el-form-item>
<el-form-item label="采集时间">
<el-date-picker
v-model="form.sampleCollectTime"
@@ -102,10 +150,11 @@
<el-select v-model="form.orderStatus" style="width: 100%" :disabled="isConverted">
<el-option label="草稿" value="draft" />
<el-option label="进行中" value="active" />
<el-option label="已转正式" value="converted" />
<el-option label="已作废" value="void" />
</el-select>
</el-form-item>
<el-form-item label="样本类型" class="form-col-span-2" required>
<el-form-item label="样本类型" required>
<el-select
v-model="form.sampleTypeIds"
multiple
@@ -122,19 +171,19 @@
/>
</el-select>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-form-item label="备注">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填"
clearable
/>
</el-form-item>
</div>
<div class="section-subtitle">意向金转账</div>
<div style="margin-bottom: 10px">
<div class="transfer-toolbar">
<el-button type="primary" size="small" :disabled="isConverted" @click="addTransfer">
<el-icon><Plus /></el-icon>
添加转账笔
@@ -143,70 +192,176 @@
已确认意向金合计 ¥{{ formatMoneyDisplay(confirmedDeposit) }}
</span>
</div>
<div
v-for="(tr, idx) in form.transfers"
:key="tr.id || idx"
class="transfer-card form-grid-2"
<el-table
:data="form.transfers"
class="list-table transfer-table"
stripe
empty-text="暂无意向金转账笔,请点击上方按钮添加"
>
<el-form-item label="转账方式">
<el-select
v-model="tr.transferType"
:disabled="isTransferLocked(tr) || isConverted"
style="width: 100%"
>
<el-option
v-for="opt in transferTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
<el-table-column type="index" label="#" min-width="50" align="center" />
<el-table-column label="转账方式" min-width="140" align="center">
<template #default="{ row }">
<el-select
v-model="row.transferType"
:disabled="isTransferLocked(row) || isConverted"
placeholder="请选择"
style="width: 100%"
>
<el-option
v-for="opt in transferTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="转账金额(元)" min-width="140" align="center">
<template #default="{ row }">
<AmountInput
v-model="row.transferAmount"
:min="0"
:disabled="isTransferLocked(row) || isConverted"
style="width: 100%"
/>
</el-select>
</el-form-item>
<el-form-item label="转账金额(元)">
<AmountInput
v-model="tr.transferAmount"
:min="0"
:disabled="isTransferLocked(tr) || isConverted"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="转账时间">
<el-date-picker
v-model="tr.transferTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled="isTransferLocked(tr) || isConverted"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态 / 操作">
<el-tag :type="payStatusTagType(String(tr.payStatus || 'dfj'))" size="small">
{{ payStatusLabel(String(tr.payStatus || 'dfj')) }}
</el-tag>
<el-button
v-if="tr.id && tr.payStatus === 'dfj' && !isConverted"
link
type="primary"
size="small"
style="margin-left: 8px"
@click="submitTransfer(tr)"
>
提交财务确认
</el-button>
<el-button
v-if="!isTransferLocked(tr) && !isConverted"
link
type="danger"
size="small"
style="margin-left: 4px"
@click="form.transfers.splice(idx, 1)"
>
删除
</el-button>
</el-form-item>
</div>
<el-empty v-if="!form.transfers.length" description="暂无意向金转账笔" :image-size="40" />
</template>
</el-table-column>
<el-table-column label="转账时间" min-width="190" align="center">
<template #default="{ row }">
<el-date-picker
v-model="row.transferTime"
type="datetime"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled="isTransferLocked(row) || isConverted"
clearable
placeholder="选择时间"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="转账凭证" min-width="280" align="left">
<template #default="{ row, $index }">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
v-if="!isTransferLocked(row) && !isConverted"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="(f: any) => handleTransferAttachmentChange($index, f)"
>
<div class="upload-card">
<el-icon :size="22" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传凭证</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in row.attachments || []"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div class="attachment-preview">
<template v-if="file.fileUrl && isImageUrl(file.fileUrl)">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else>
<div class="attachment-preview__icon">
<el-icon :size="22"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{
file.fileName || '附件'
}}</span>
<el-icon
v-if="!isTransferLocked(row) && !isConverted"
class="action-btn"
title="删除"
@click="removeTransferAttachment($index, fIdx)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="备注" min-width="160" align="center">
<template #default="{ row }">
<el-input
v-if="!isTransferLocked(row) && !isConverted"
v-model="row.remark"
maxlength="512"
clearable
placeholder="选填"
/>
<span v-else>{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column
label="财务备注"
min-width="140"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="payStatusTagType(String(row.payStatus || 'dfj'))" size="small">
{{ payStatusLabel(String(row.payStatus || 'dfj')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="220" align="center" fixed="right">
<template #default="{ row, $index }">
<el-button
v-if="row.id && row.payStatus === 'dfj' && !isConverted"
link
type="primary"
size="small"
@click="submitTransfer(row)"
>
<el-icon><Check /></el-icon>
提交财务确认
</el-button>
<el-button
v-if="row.id && row.payStatus === 'jfqrz' && !isConverted"
link
type="warning"
size="small"
@click="withdrawTransfer(row)"
>
<el-icon><RefreshLeft /></el-icon>
撤回
</el-button>
<el-button
v-if="!isTransferLocked(row) && !isConverted"
link
type="danger"
size="small"
@click="form.transfers.splice($index, 1)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<span
v-if="
isConverted ||
(isTransferLocked(row) &&
!(row.id && (row.payStatus === 'dfj' || row.payStatus === 'jfqrz')))
"
class="muted-dash"
>—</span
>
</template>
</el-table-column>
</el-table>
</el-form>
</DialogFormBody>
@@ -231,7 +386,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Close, Check } from '@element-plus/icons-vue'
import { Plus, Close, Check, Document, Delete, RefreshLeft } from '@element-plus/icons-vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
@@ -241,10 +396,14 @@
getTransferTypeOptions
} from '@/utils/protocol-payment-labels'
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
import { useUserStore } from '@/store/modules/user'
import { fetchUploadAttachment } from '@/api/business'
import {
fetchCellSampleTypeList,
fetchCollectionRegionList,
fetchHospitalList
fetchHospitalList,
fetchRegionStaffList,
fetchUserList
} from '@/api/admin'
import {
fetchPresignOrderDetail,
@@ -252,9 +411,12 @@
fetchUpdatePresignOrder,
fetchCreatePresignTransfer,
fetchUpdatePresignTransfer,
fetchSubmitPresignTransferFinance
fetchSubmitPresignTransferFinance,
fetchWithdrawPresignTransfer
} from '@/api/presign-order'
const userStore = useUserStore()
const props = defineProps<{
modelValue: boolean
orderId?: string
@@ -272,14 +434,71 @@
const sampleTypeOptions = ref<Array<Record<string, any>>>([])
const regionOptions = ref<Array<Record<string, any>>>([])
const hospitalOptions = ref<Array<Record<string, any>>>([])
const collectorUserOptions = ref<Api.User.UserItem[]>([])
const managerUserOptions = ref<Api.User.UserItem[]>([])
const collectorLoading = ref(false)
const managerLoading = ref(false)
function todayYmd() {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}${m}${day}`
}
function userStubFromStore(): Api.User.UserItem | null {
const currentUser = userStore.getUserInfo
const uid = String(currentUser?.id || currentUser?.userId || '')
if (!uid) return null
return {
id: uid,
nickName: String(
currentUser?.realName || currentUser?.userName || currentUser?.username || ''
),
phoneNo: String(currentUser?.phone || currentUser?.phoneNo || ''),
deptId: '',
userNo: '',
email: '',
sex: '',
avatar: '',
status: '1',
tenantNo: '',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
} as Api.User.UserItem
}
function makeUserStub(id: string, name: string): Api.User.UserItem {
return {
id,
nickName: name || id,
phoneNo: '',
deptId: '',
userNo: '',
email: '',
sex: '',
avatar: '',
status: '1',
tenantNo: '',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
} as Api.User.UserItem
}
const form = reactive({
orderDate: '',
orderDate: todayYmd(),
protocolNo: '',
motherName: '',
expectedDeliveryDate: '',
regionId: '',
hospitalId: '',
collectorUserId: '',
regionManagerUserId: '',
sampleCollectTime: '',
payMode: 'dj',
contractAmount: null as number | null,
@@ -297,13 +516,106 @@
return tr.payStatus === 'jfqrz' || tr.payStatus === 'yjf'
}
type TransferAttachment = {
id?: string
fileName?: string
fileUrl?: string
_uploading?: boolean
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
}
function attachmentsToUrls(files: TransferAttachment[] | undefined) {
return (files || [])
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => String(f.fileUrl))
.join(',')
}
function normalizeTransfer(row: Record<string, any>) {
const files = Array.isArray(row.attachments) ? row.attachments : []
return {
...row,
attachments: files.map((f: Record<string, any>, i: number) => ({
id: f.id || `att_${i}`,
fileName: f.fileName || f.name || '附件',
fileUrl: f.fileUrl || f.url || ''
})) as TransferAttachment[]
}
}
async function handleTransferAttachmentChange(index: number, uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
if (file.size > 20 * 1024 * 1024) {
ElMessage.warning('单文件不超过 20MB')
return
}
const row = form.transfers[index]
if (!row) return
if (!Array.isArray(row.attachments)) row.attachments = []
const tempId = `temp_${Date.now()}_${Math.random().toString(16).slice(2)}`
row.attachments.push({
id: tempId,
fileName: file.name,
fileUrl: '',
_uploading: true
})
const idx = row.attachments.length - 1
try {
const fd = new FormData()
fd.append('folder', 'presign-transfer')
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
const data = res?.data || res
row.attachments[idx] = {
id: data?.id || tempId,
fileName: data?.fileName || file.name,
fileUrl: data?.fileUrl || data?.url || '',
_uploading: false
}
} catch {
ElMessage.error('文件上传失败')
row.attachments.splice(idx, 1)
}
}
function removeTransferAttachment(rowIndex: number, fileIndex: number) {
const row = form.transfers[rowIndex]
if (!row?.attachments) return
row.attachments.splice(fileIndex, 1)
}
function applyDefaultCollector() {
const stub = userStubFromStore()
if (!stub) {
form.collectorUserId = ''
collectorUserOptions.value = []
return
}
form.collectorUserId = stub.id
collectorUserOptions.value = [stub]
}
function resetForm() {
form.orderDate = ''
form.orderDate = todayYmd()
form.protocolNo = ''
form.motherName = ''
form.expectedDeliveryDate = ''
form.regionId = ''
form.hospitalId = ''
form.collectorUserId = ''
form.regionManagerUserId = ''
form.sampleCollectTime = ''
form.payMode = 'dj'
form.contractAmount = null
@@ -312,6 +624,90 @@
form.remark = ''
form.transfers = []
form.confirmedDepositAmount = 0
collectorUserOptions.value = []
managerUserOptions.value = []
applyDefaultCollector()
}
async function remoteSearchCollectorUsers(keyword: string) {
collectorLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: (keyword || '').trim() || undefined,
status: '1'
})
let list = res.list || []
const cur = form.collectorUserId
if (cur && !list.some((x) => x.id === cur)) {
const keep = collectorUserOptions.value.find((x) => x.id === cur)
if (keep) list = [keep, ...list]
}
collectorUserOptions.value = list
} catch {
collectorUserOptions.value = []
} finally {
collectorLoading.value = false
}
}
async function remoteSearchManagerUsers(keyword: string) {
managerLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: (keyword || '').trim() || undefined,
status: '1'
})
let list = res.list || []
const cur = form.regionManagerUserId
if (cur && !list.some((x) => x.id === cur)) {
const keep = managerUserOptions.value.find((x) => x.id === cur)
if (keep) list = [keep, ...list]
}
managerUserOptions.value = list
} catch {
managerUserOptions.value = []
} finally {
managerLoading.value = false
}
}
function onCollectorUserChange(val: string | null) {
form.collectorUserId = val || ''
}
function onManagerUserChange(val: string | null) {
form.regionManagerUserId = val || ''
}
async function applyDefaultRegionManager(regionId: string) {
if (!regionId) {
form.regionManagerUserId = ''
managerUserOptions.value = []
return
}
try {
const res: any = await fetchRegionStaffList(regionId, { page: 1, pageSize: 100 })
const list = (res?.list || []) as Array<Record<string, any>>
const leader = list.find((x) => x.isLeader) || list[0]
if (!leader?.userId) {
form.regionManagerUserId = ''
return
}
const stub = makeUserStub(
String(leader.userId),
String(leader.nickName || leader.userName || '')
)
form.regionManagerUserId = stub.id
if (!managerUserOptions.value.some((x) => x.id === stub.id)) {
managerUserOptions.value = [stub, ...managerUserOptions.value]
}
} catch {
/* 地区无人员配置时留空,可手工选择 */
}
}
function addTransfer() {
@@ -319,7 +715,10 @@
transferType: '',
transferAmount: null,
transferTime: '',
payStatus: 'dfj'
payStatus: 'dfj',
remark: '',
financeRemark: '',
attachments: [] as TransferAttachment[]
})
}
@@ -327,8 +726,8 @@
await loadTransferTypeDict()
transferTypeOptions.value = getTransferTypeOptions()
const [types, regions] = await Promise.all([
fetchCellSampleTypeList({ page: 1, pageSize: 200 }),
fetchCollectionRegionList({ page: 1, pageSize: 200 })
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
fetchCollectionRegionList({ page: 1, pageSize: 100 })
])
sampleTypeOptions.value = (types as any)?.list || (types as any) || []
regionOptions.value = (regions as any)?.list || (regions as any) || []
@@ -337,13 +736,13 @@
async function loadHospitals() {
hospitalOptions.value = []
if (!form.regionId) return
const res: any = await fetchHospitalList(form.regionId, { page: 1, pageSize: 200 })
const res: any = await fetchHospitalList(form.regionId, { page: 1, pageSize: 100 })
hospitalOptions.value = res?.list || []
}
async function onRegionChange() {
form.hospitalId = ''
await loadHospitals()
await Promise.all([loadHospitals(), applyDefaultRegionManager(form.regionId)])
}
async function loadDetail() {
@@ -357,14 +756,28 @@
form.expectedDeliveryDate = String(data.expectedDeliveryDate || '')
form.regionId = String(data.regionId || '')
form.hospitalId = String(data.hospitalId || '')
form.collectorUserId = String(data.collectorUserId || '')
form.regionManagerUserId = String(data.regionManagerUserId || '')
form.sampleCollectTime = String(data.sampleCollectTime || '')
form.payMode = String(data.payMode || 'dj')
form.contractAmount = data.contractAmount ?? null
form.orderStatus = String(data.orderStatus || 'active')
form.sampleTypeIds = Array.isArray(data.sampleTypeIds) ? [...data.sampleTypeIds] : []
form.remark = String(data.remark || '')
form.transfers = Array.isArray(data.transfers) ? [...data.transfers] : []
form.transfers = Array.isArray(data.transfers)
? data.transfers.map((t: Record<string, any>) => normalizeTransfer(t))
: []
form.confirmedDepositAmount = Number(data.confirmedDepositAmount || 0)
if (form.collectorUserId) {
collectorUserOptions.value = [
makeUserStub(form.collectorUserId, String(data.collectorUserName || ''))
]
}
if (form.regionManagerUserId) {
managerUserOptions.value = [
makeUserStub(form.regionManagerUserId, String(data.regionManagerUserName || ''))
]
}
if (form.regionId) await loadHospitals()
} finally {
loading.value = false
@@ -380,7 +793,9 @@
await fetchUpdatePresignTransfer(String(tr.id), {
transferType: tr.transferType,
transferAmount: tr.transferAmount,
transferTime: tr.transferTime || undefined
transferTime: tr.transferTime || undefined,
transferAttachments: attachmentsToUrls(tr.attachments) || undefined,
remark: tr.remark || undefined
})
}
await fetchSubmitPresignTransferFinance(String(tr.id))
@@ -388,6 +803,18 @@
ElMessage.success('已提交财务确认')
}
async function withdrawTransfer(tr: Record<string, any>) {
if (!tr.id) return
await ElMessageBox.confirm(
'确认撤回该笔意向金的财务确认申请?撤回后可修改并重新提交。',
'撤回',
{ type: 'warning' }
)
await fetchWithdrawPresignTransfer(String(tr.id))
tr.payStatus = 'dfj'
ElMessage.success('已撤回')
}
async function handleSave() {
if (!form.motherName.trim()) {
ElMessage.warning('请填写母亲姓名')
@@ -407,6 +834,8 @@
regionId: form.regionId || undefined,
hospitalId: form.hospitalId || undefined,
sampleCollectTime: form.sampleCollectTime || undefined,
collectorUserId: form.collectorUserId || undefined,
regionManagerUserId: form.regionManagerUserId || undefined,
payMode: form.payMode || undefined,
contractAmount: form.contractAmount ?? undefined,
orderStatus: form.orderStatus || 'active',
@@ -420,13 +849,17 @@
await fetchCreatePresignTransfer(props.orderId, {
transferType: tr.transferType,
transferAmount: tr.transferAmount,
transferTime: tr.transferTime || undefined
transferTime: tr.transferTime || undefined,
transferAttachments: attachmentsToUrls(tr.attachments) || undefined,
remark: tr.remark || undefined
})
} else if (tr.id && tr.payStatus === 'dfj') {
await fetchUpdatePresignTransfer(String(tr.id), {
transferType: tr.transferType,
transferAmount: tr.transferAmount,
transferTime: tr.transferTime || undefined
transferTime: tr.transferTime || undefined,
transferAttachments: attachmentsToUrls(tr.attachments) || undefined,
remark: tr.remark || undefined
})
}
}
@@ -436,7 +869,9 @@
.map((t) => ({
transferType: t.transferType,
transferAmount: t.transferAmount,
transferTime: t.transferTime || undefined
transferTime: t.transferTime || undefined,
transferAttachments: attachmentsToUrls(t.attachments) || undefined,
remark: t.remark || undefined
}))
await fetchCreatePresignOrder(payload)
}
@@ -459,16 +894,6 @@
</script>
<style scoped lang="scss">
.form-grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.form-col-span-2 {
grid-column: 1 / -1;
}
.section-subtitle {
margin: 16px 0 8px;
font-size: 13px;
@@ -476,17 +901,114 @@
color: var(--mint-text-secondary, #475569);
}
.transfer-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 10px;
}
.deposit-hint {
margin-left: 12px;
font-size: 12px;
color: #047857;
}
.transfer-card {
margin-bottom: 10px;
padding: 10px 12px;
.transfer-table {
width: 100%;
:deep(.el-date-editor) {
width: 100%;
}
}
.muted-dash {
color: var(--mint-text-muted, #94a3b8);
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.upload-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 88px;
height: 72px;
cursor: pointer;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #ecfdf5;
}
.upload-card-text {
margin-top: 4px;
font-size: 11px;
color: #047857;
}
.attachment-item {
width: 88px;
overflow: hidden;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&.is-uploading {
opacity: 0.6;
}
}
.attachment-preview {
display: flex;
align-items: center;
justify-content: center;
width: 88px;
height: 52px;
background: #f8fafc;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.attachment-preview__icon {
color: #64748b;
}
.attachment-footer {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 6px;
}
.attachment-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.action-btn {
flex-shrink: 0;
cursor: pointer;
color: #94a3b8;
&:hover {
color: #dc2626;
}
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff