This commit is contained in:
zlwu committed 2026-05-17 12:31:11 +08:00
1 parent e5ba8f097a
commit 62cfb8fbbf
24 files changed
+3323 -249

No files matched your search

+3 -1
View File
@@ -56,6 +56,8 @@
const buttons = document.querySelectorAll<HTMLElement>(selectors.join(','))
buttons.forEach((button) => {
autoIconClassList.forEach((className) => button.classList.remove(className))
// 区域/状态 Tab 仅展示文案,不参与按钮自动图标
if (button.classList.contains('region-tab-btn')) return
const text = (button.textContent || '').replace(/\s+/g, '')
const matched = iconClassByText.find(([rule]) => rule.test(text))
if (matched) button.classList.add(matched[1])
@@ -78,7 +80,7 @@
const panelTitleClassList = panelTitleIconByText.map(([, cls]) => cls)
const panelTitleBarClass = 'title-bar-heading'
const panelTitleBarTextRules: RegExp[] = [
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|收款确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表)/
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表)/
]
const refreshPanelTitleIcons = () => {
+64 -5
View File
@@ -68,9 +68,16 @@ export function fetchReceiptConfirmList(params?: { page?: number; pageSize?: num
})
}
export function fetchReceiptConfirmedList(params?: { page?: number; pageSize?: number }) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details/receipt-confirmed',
params
})
}
export function fetchReceiptConfirmSubmit(
detailId: string,
data?: { financeConfirmAttachments?: string }
data?: { financeConfirmAttachments?: string; financeRemark?: string }
) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/receipt-confirm`,
@@ -86,9 +93,16 @@ export function fetchRefundConfirmList(params?: { page?: number; pageSize?: numb
})
}
export function fetchRefundConfirmedList(params?: { page?: number; pageSize?: number }) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details/refund-confirmed',
params
})
}
export function fetchRefundConfirmSubmit(
detailId: string,
data?: { financeConfirmAttachments?: string }
data?: { refundFinanceConfirmAttachments?: string; refundFinanceRemark?: string }
) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/refund-confirm`,
@@ -97,13 +111,58 @@ export function fetchRefundConfirmSubmit(
})
}
export function fetchInstallmentWarningList(params?: {
export function fetchSubmitReceiptFinance(detailId: string) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/submit-receipt-finance`,
showSuccessMessage: true
})
}
export function fetchSubmitReceiptFinanceBatch(detailIds: string[]) {
return request.post({
url: '/web/admin/protocol-payment-details/submit-receipt-finance-batch',
data: { detailIds },
showSuccessMessage: true
})
}
export function fetchSubmitRefundFinance(
detailId: string,
data: {
refundAmount: number
refundRemark?: string
refundAttachments?: string
}
) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/submit-refund-finance`,
data,
showSuccessMessage: true
})
}
export function fetchSubmitRefundFinanceBatch(data: {
detailIds: string[]
refundAmount: number
refundRemark?: string
refundAttachments?: string
}) {
return request.post({
url: '/web/admin/protocol-payment-details/submit-refund-finance-batch',
data,
showSuccessMessage: true
})
}
export function fetchPaymentDetailList(params?: {
page?: number
pageSize?: number
warningType?: string
payStatus?: string
keyword?: string
transferType?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details/installment-warning',
url: '/web/admin/protocol-payment-details',
params
})
}
@@ -0,0 +1,14 @@
export type FinanceAttachmentItem = {
id: string
fileName: string
fileUrl: string
fileType?: string
_uploading?: boolean
}
export function financeAttachmentsToUrlString(items: FinanceAttachmentItem[]) {
return items
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => f.fileUrl)
.join(',')
}
@@ -0,0 +1,320 @@
<!-- 财务确认弹窗:附件上传 + 财务备注(对齐采集协议单附件 UI) -->
<template>
<div class="protocol-finance-confirm-fields">
<el-form-item label="财务备注" class="form-col-span-2">
<el-input
:model-value="financeRemark"
type="textarea"
:rows="3"
placeholder="选填"
@update:model-value="emit('update:financeRemark', $event)"
/>
</el-form-item>
<el-form-item label="财务确认附件" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleAttachmentChange"
>
<div class="upload-card">
<el-icon :size="22" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in attachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="isImageFile(file) && file.fileUrl ? handlePreview(file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
v-if="file.fileUrl"
class="action-btn"
title="下载"
@click.stop="downloadFile(file)"
>
<Download />
</el-icon>
<el-icon class="action-btn" title="删除" @click.stop="removeAttachment(fIdx)">
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<p class="upload-hint">支持图片、视频、文档等,单文件不超过 20MB。</p>
</div>
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import {
Plus,
Document,
Delete,
Download,
Loading,
VideoPlay
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import type { FinanceAttachmentItem } from './finance-attachments'
const props = withDefaults(
defineProps<{
financeRemark: string
attachments: FinanceAttachmentItem[]
uploadFolder?: string
}>(),
{ uploadFolder: 'protocol-finance-confirm' }
)
const emit = defineEmits<{
(e: 'update:financeRemark', val: string): void
(e: 'update:attachments', val: FinanceAttachmentItem[]): void
}>()
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 isImageFile(file: FinanceAttachmentItem) {
const t = file.fileType || ''
const n = file.fileName || ''
return t.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(n)
}
function isVideoFile(file: FinanceAttachmentItem) {
const t = file.fileType || ''
const n = file.fileName || ''
return t.startsWith('video/') || /\.(mp4|webm|mov|avi|mkv)$/i.test(n)
}
function handlePreview(file: FinanceAttachmentItem) {
if (!file.fileUrl) return
window.open(fullFileUrl(file.fileUrl), '_blank')
}
function downloadFile(file: FinanceAttachmentItem) {
if (!file.fileUrl) return
const a = document.createElement('a')
a.href = fullFileUrl(file.fileUrl)
a.download = file.fileName || 'download'
a.target = '_blank'
a.rel = 'noopener'
a.click()
}
function removeAttachment(index: number) {
const next = [...props.attachments]
next.splice(index, 1)
emit('update:attachments', next)
}
async function handleAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
const maxSizeMb = 20
if (file.size / 1024 / 1024 > maxSizeMb) {
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
return
}
const tempId = `temp_${Date.now()}`
const uploading: FinanceAttachmentItem = {
id: tempId,
fileName: file.name,
fileUrl: '',
fileType: file.type || '',
_uploading: true
}
const listWithTemp = [...props.attachments, uploading]
emit('update:attachments', listWithTemp)
const idx = listWithTemp.length - 1
try {
const fd = new FormData()
fd.append('folder', props.uploadFolder)
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
const updated = [...listWithTemp]
updated[idx] = {
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
fileType: res.fileType || file.type || '',
_uploading: false
}
emit('update:attachments', updated)
} catch {
ElMessage.error('文件上传失败')
emit(
'update:attachments',
props.attachments.filter((a) => a.id !== tempId)
)
}
}
</script>
<style scoped lang="scss">
.protocol-finance-confirm-fields {
width: 100%;
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card {
width: 120px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #10b981;
border-style: solid;
background: #ecfdf5;
}
}
.upload-card-text {
font-size: 12px;
color: #10b981;
font-weight: 500;
line-height: 1;
}
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
margin-top: 8px;
}
.attachment-item {
width: 120px;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
&.is-uploading {
opacity: 0.6;
pointer-events: none;
}
}
.attachment-preview {
width: 100%;
height: 96px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.attachment-preview__icon {
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
&--video {
color: #10b981;
}
}
.attachment-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 6px 8px;
min-height: 32px;
}
.attachment-name {
flex: 1;
min-width: 0;
font-size: 12px;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.action-btn {
cursor: pointer;
color: #94a3b8;
font-size: 16px;
&:hover {
color: #10b981;
}
}
</style>
@@ -0,0 +1,253 @@
<!-- 缴费明细只读信息(核对用) -->
<template>
<div class="protocol-payment-detail-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?.protocolNo) }}</dd>
</div>
<div class="view-kv">
<dt>协议状态</dt>
<dd>{{ protocolStatusLabel(String(detail?.protocolStatus || '')) }}</dd>
</div>
<div class="view-kv">
<dt>缴费方式</dt>
<dd>{{ payModeLabel(String(detail?.payMode || '')) }}</dd>
</div>
<div class="view-kv">
<dt>缴费状态</dt>
<dd>{{ payStatusLabel(String(detail?.payStatus || '')) }}</dd>
</div>
<template v-if="showCustomer">
<div class="view-kv">
<dt>客户姓名</dt>
<dd>{{ dash(detail?.motherName) }}</dd>
</div>
<div class="view-kv">
<dt>身份证号</dt>
<dd>{{ maskIdCard(String(detail?.motherIdCard || '')) }}</dd>
</div>
<div class="view-kv">
<dt>新生儿姓名</dt>
<dd>{{ dash(detail?.newbornName) }}</dd>
</div>
<div class="view-kv">
<dt>新生儿身份证号</dt>
<dd>{{ maskIdCard(String(detail?.newbornIdCard || '')) }}</dd>
</div>
</template>
<div v-if="detail?.payMode === 'fq'" class="view-kv">
<dt>分期期序号</dt>
<dd>{{ dash(detail?.installmentNo) }}</dd>
</div>
<div v-if="detail?.payMode === 'fq'" class="view-kv">
<dt>应缴费时间</dt>
<dd>{{ formatPaymentDueDate(detail?.installmentDueDate) }}</dd>
</div>
<div class="view-kv">
<dt>应缴费金额</dt>
<dd>{{ formatPaymentAmount(detail?.dueAmount) }}</dd>
</div>
<div class="view-kv">
<dt>转账方式</dt>
<dd>{{ transferTypeLabel(String(detail?.transferType || '')) }}</dd>
</div>
<div class="view-kv">
<dt>转账金额</dt>
<dd>{{ formatPaymentAmount(detail?.transferAmount) }}</dd>
</div>
<div class="view-kv">
<dt>转账时间</dt>
<dd>{{ dash(detail?.transferTime) }}</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 payment-detail-readonly__attachments">
<div class="view-field-block__label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</dl>
<template v-if="showRefundApply && hasRefundApplyInfo">
<div class="form-section-divider form-section-divider--inner" />
<h4 v-if="refundApplyTitle" class="detail-block-title">{{ refundApplyTitle }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>退费金额</dt>
<dd>{{ formatPaymentAmount(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 payment-detail-readonly__attachments">
<div class="view-field-block__label">退费附件</div>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</dl>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { maskIdCard } from '@/utils/mask-sensitive'
import {
formatPaymentAmount,
formatPaymentDueDate,
payModeLabel,
payStatusLabel,
protocolStatusLabel,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
const props = withDefaults(
defineProps<{
detail?: Record<string, unknown> | null
title?: string
showCustomer?: boolean
showRefundApply?: boolean
refundApplyTitle?: string
}>(),
{ showCustomer: true, showRefundApply: false, refundApplyTitle: '退费申请(请核对)' }
)
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)
}
const transferAttachmentFiles = computed(() => {
const row = props.detail
if (!row) return []
if (Array.isArray(row.attachments)) {
return row.attachments as Array<Record<string, unknown>>
}
const urls = String(row.transferAttachments || '')
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 refundAttachmentFiles = computed(() => {
const row = props.detail
if (!row) return []
if (Array.isArray(row.refundAttachmentFiles)) {
return row.refundAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(row.refundAttachments || '')
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 hasRefundApplyInfo = computed(() => {
const row = props.detail
if (!row) return false
const amount = row.refundAmount
const hasAmount = amount != null && amount !== '' && Number(amount) > 0
return (
hasAmount ||
String(row.refundRemark || '').trim() !== '' ||
refundAttachmentFiles.value.length > 0
)
})
</script>
<style scoped lang="scss">
.protocol-payment-detail-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;
}
.payment-detail-readonly__attachments {
margin-top: 4px;
}
.form-section-divider--inner {
height: 1px;
margin: 16px 0;
background: var(--mint-border, #e2e8f0);
}
</style>
+8 -8
View File
@@ -129,6 +129,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/cell-storage/protocol/index',
meta: { title: '采集协议单', keepAlive: true }
},
{
id: '50000000000000000000000000000007',
name: 'CellStoragePaymentInfo',
path: 'payment-info',
component: '/cell-storage/payment-info/index',
meta: { title: '缴费信息', keepAlive: true }
},
{
id: '50000000000000000000000000000003',
name: 'CellStorageProtocolAudit',
@@ -141,7 +148,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
name: 'CellStoragePaymentConfirm',
path: 'payment-confirm',
component: '/cell-storage/payment-confirm/index',
meta: { title: '收款确认(财务)', keepAlive: true }
meta: { title: '缴费确认(财务)', keepAlive: true }
},
{
id: '50000000000000000000000000000005',
@@ -149,13 +156,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
path: 'refund-confirm',
component: '/cell-storage/refund-confirm/index',
meta: { title: '退费确认(财务)', keepAlive: true }
},
{
id: '50000000000000000000000000000006',
name: 'CellStorageInstallmentWarning',
path: 'installment-warning',
component: '/cell-storage/installment-warning/index',
meta: { title: '分期缴费预警', keepAlive: true }
}
]
},
+18
View File
@@ -0,0 +1,18 @@
/** 身份证号脱敏:18 位保留前 6 后 4,15 位保留前 6 后 3 */
export function maskIdCard(value: unknown): string {
const raw = String(value ?? '').trim()
if (!raw) return '-'
const len = raw.length
if (len === 18) {
return `${raw.slice(0, 6)}${'*'.repeat(8)}${raw.slice(14)}`
}
if (len === 15) {
return `${raw.slice(0, 6)}${'*'.repeat(6)}${raw.slice(12)}`
}
if (len <= 4) {
return '*'.repeat(len)
}
const head = Math.min(3, Math.floor(len / 3))
const tail = Math.min(4, len - head - 1)
return `${raw.slice(0, head)}${'*'.repeat(len - head - tail)}${raw.slice(len - tail)}`
}
@@ -0,0 +1,51 @@
export function protocolStatusLabel(s: string) {
const map: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
ygq: '已过期',
yzf: '已作废',
zky: '转科研'
}
return map[s] || s || '-'
}
export function payModeLabel(s: string) {
if (s === 'dj') return '趸交'
if (s === 'fq') return '分期'
return s || '-'
}
export function payStatusLabel(s: string) {
const map: Record<string, string> = {
dfj: '待缴费',
jfqrz: '缴费确认中',
yjf: '已缴费',
tfqrz: '退费确认中',
ytf: '已退费'
}
return map[s] || '未知'
}
export function transferTypeLabel(t: string) {
const map: Record<string, string> = {
wxzz: '微信转账',
zzbzz: '支付宝转账',
yhkzz: '银行卡转账',
xj: '现金'
}
return map[t] || t || '-'
}
export function formatPaymentDueDate(d: unknown) {
const s = String(d || '')
if (!s) return '-'
if (s.length === 8) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
return s
}
export function formatPaymentAmount(v: unknown) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
@@ -1,107 +0,0 @@
<!-- 分期缴费预警 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>分期缴费预警</h1>
<p class="page-desc">仅展示待缴费分期;预警窗口为未来 15 天内应缴;逾期为应缴日早于今日。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">分期预警列表</h2>
</div>
<div class="filter-block">
<el-form class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="范围">
<el-select v-model="warningType" style="width: 100%" @change="handleSearch">
<el-option label="全部" value="all" />
<el-option label="预警(15天内)" value="warn" />
<el-option label="逾期" value="overdue" />
</el-select>
</el-form-item>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe row-key="id">
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column prop="protocolNo" label="协议号" min-width="130" align="center" header-align="center" />
<el-table-column prop="installmentNo" label="期数" width="80" align="center" header-align="center" />
<el-table-column prop="installmentDueDate" label="应缴日" width="120" align="center" header-align="center" />
<el-table-column prop="warningType" label="分级" width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="warningTagType(String(row.warningType || ''))" effect="plain">{{
warningTypeLabel(String(row.warningType || ''))
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="transferAmount" label="金额" width="120" align="center" header-align="center" />
</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>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { fetchInstallmentWarningList } from '@/api/collection-protocol'
defineOptions({ name: 'CellStorageInstallmentWarning' })
function warningTypeLabel(w: string) {
if (w === 'overdue') return '逾期'
if (w === 'warn') return '预警'
if (w === 'upcoming') return '未到期'
return '其他'
}
function warningTagType(w: string) {
if (w === 'overdue') return 'danger'
if (w === 'warn') return 'warning'
return 'info'
}
const warningType = ref('all')
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
async function loadData() {
loading.value = true
try {
const res = await fetchInstallmentWarningList({
page: pagination.page,
pageSize: pagination.pageSize,
warningType: warningType.value
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
onMounted(() => {
loadData()
})
</script>
@@ -1,31 +1,132 @@
<!-- 收款确认(财务) -->
<!-- 缴费确认(财务) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>收款确认</h1>
<p class="page-desc">仅处理「缴费确认中」明细,确认后变为已缴费。</p>
<h1>缴费确认</h1>
<p class="page-desc">
待确认:处理「缴费确认中」明细;已确认:查看财务确认收款记录及当时填写的备注与附件。
</p>
</header>
<div class="region-tabs region-tab-strip">
<div class="tab-button-group region-tab-group">
<el-button
v-for="tab in tabs"
:key="tab.key"
class="region-tab-btn"
:type="activeTab === tab.key ? 'primary' : 'default'"
@click="switchTab(tab.key)"
>
<span class="region-tab-inner">
<span class="region-tab-name">{{ tab.label }}</span>
</span>
</el-button>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">收款确认列表</h2>
<h2 class="title-bar-heading">缴费确认列表</h2>
</div>
<div class="list-body list-wrap">
<div class="list-body list-wrap payment-confirm-table-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe row-key="id">
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="protocolNo" label="协议号" min-width="130" align="center" header-align="center" />
<el-table-column prop="installmentNo" label="期数" width="80" align="center" header-align="center" />
<el-table-column prop="installmentDueDate" label="应缴日" width="120" align="center" header-align="center" />
<el-table-column prop="transferAmount" label="金额" width="120" align="center" header-align="center" />
<el-table-column prop="payStatus" label="状态" width="120" align="center" header-align="center">
<template #default>缴费确认中</template>
<el-table-column prop="motherName" label="客户姓名" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.motherName || '-' }}</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
<el-table-column prop="motherIdCard" label="身份证号" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ maskIdCard(row.motherIdCard) }}</template>
</el-table-column>
<el-table-column prop="newbornName" label="新生儿姓名" min-width="110" align="center" header-align="center">
<template #default="{ row }">{{ row.newbornName || '-' }}</template>
</el-table-column>
<el-table-column
prop="newbornIdCard"
label="新生儿身份证号"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ maskIdCard(row.newbornIdCard) }}</template>
</el-table-column>
<el-table-column prop="protocolStatus" label="协议状态" width="100" align="center" header-align="center">
<template #default="{ row }">
{{ protocolStatusLabel(String(row.protocolStatus || '')) }}
</template>
</el-table-column>
<el-table-column prop="payMode" label="缴费方式" width="100" align="center" header-align="center">
<template #default="{ row }">{{ payModeLabel(String(row.payMode || '')) }}</template>
</el-table-column>
<el-table-column prop="installmentNo" label="期数" width="80" align="center" header-align="center">
<template #default="{ row }">{{ row.installmentNo ?? '-' }}</template>
</el-table-column>
<el-table-column prop="installmentDueDate" label="应缴费时间" width="120" align="center" header-align="center">
<template #default="{ row }">{{ formatPaymentDueDate(row.installmentDueDate) }}</template>
</el-table-column>
<el-table-column prop="dueAmount" label="应缴金额(元)" width="120" align="center" header-align="center">
<template #default="{ row }">{{ formatPaymentAmount(row.dueAmount) }}</template>
</el-table-column>
<el-table-column prop="transferAmount" label="实缴金额(元)" width="120" align="center" header-align="center">
<template #default="{ row }">{{ formatPaymentAmount(row.transferAmount) }}</template>
</el-table-column>
<el-table-column prop="transferType" label="转账方式" width="110" align="center" header-align="center">
<template #default="{ row }">{{ transferTypeLabel(String(row.transferType || '')) }}</template>
</el-table-column>
<el-table-column prop="transferTime" label="转账时间" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ row.transferTime || '-' }}</template>
</el-table-column>
<el-table-column prop="payStatus" label="状态" width="110" align="center" header-align="center">
<template #default>
{{ activeTab === 'pending' ? '缴费确认中' : '已缴费' }}
</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="updateTime"
label="确认时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="financeRemark"
label="财务备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '-' }}</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'pending'"
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column label="操作" width="140" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openConfirm(row)">确认收款</el-button>
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
@click="openConfirm(row)"
>
确认收款
</el-button>
<el-button v-else class="btn-op-item" @click="openViewConfirm(row)">查看确认信息</el-button>
</template>
</el-table-column>
</el-table>
@@ -45,15 +146,26 @@
<el-dialog
v-model="dialogVisible"
title="确认收款"
width="560px"
class="dialog-form-layout"
width="900px"
class="dialog-form-layout payment-confirm-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<ProtocolPaymentDetailReadonly
:detail="confirmRow"
title="缴费明细(请核对)"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认</h4>
<el-form label-position="top" class="biz-dialog-form">
<el-form-item label="财务确认附件(MinIO 地址,逗号分隔,可选)">
<el-input v-model="confirmAttachments" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
<ProtocolFinanceConfirmFields
v-model:finance-remark="financeRemark"
v-model:attachments="attachments"
upload-folder="protocol-finance-receipt"
/>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
@@ -66,32 +178,150 @@
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="viewDialogVisible"
title="确认收款信息"
width="900px"
class="dialog-form-layout payment-confirm-dialog"
align-center
destroy-on-close
>
<ProtocolPaymentDetailReadonly
:detail="viewRow"
title="缴费明细"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认信息</h4>
<el-form label-position="top" class="biz-dialog-form view-confirm-form">
<div class="form-grid-2">
<el-form-item label="确认时间" class="form-col-span-2">
<span class="view-field-text">{{ viewRow?.updateTime || '-' }}</span>
</el-form-item>
<el-form-item label="财务备注" class="form-col-span-2">
<span class="view-field-text view-field-text--multiline">{{
viewRow?.financeRemark || '—'
}}</span>
</el-form-item>
<el-form-item label="财务确认附件" class="form-col-span-2">
<ProtocolAttachmentReadonly
:files="viewFinanceAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="viewDialogVisible = false">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check } from '@element-plus/icons-vue'
import { fetchReceiptConfirmList, fetchReceiptConfirmSubmit } from '@/api/collection-protocol'
import {
fetchReceiptConfirmList,
fetchReceiptConfirmedList,
fetchReceiptConfirmSubmit
} from '@/api/collection-protocol'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolPaymentDetailReadonly from '@/components/business/protocol-payment-detail-readonly/index.vue'
import ProtocolAttachmentReadonly from '../protocol/ProtocolAttachmentReadonly.vue'
import {
financeAttachmentsToUrlString,
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { maskIdCard } from '@/utils/mask-sensitive'
import {
formatPaymentAmount,
formatPaymentDueDate,
payModeLabel,
protocolStatusLabel,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
defineOptions({ name: 'CellStoragePaymentConfirm' })
const tabs = [
{ key: 'pending', label: '待确认' },
{ key: 'confirmed', label: '已确认' }
]
const activeTab = ref('pending')
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const viewDialogVisible = ref(false)
const submitLoading = ref(false)
const confirmAttachments = ref('')
const financeRemark = ref('')
const attachments = ref<FinanceAttachmentItem[]>([])
const currentDetailId = ref<string | null>(null)
const confirmRow = ref<Record<string, unknown> | null>(null)
const viewRow = ref<Record<string, unknown> | null>(null)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const viewFinanceAttachmentFiles = computed(() => {
const row = viewRow.value
if (!row) return []
if (Array.isArray(row.financeConfirmAttachmentFiles)) {
return row.financeConfirmAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(row.financeConfirmAttachments || '')
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>>
})
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
window.open(fullFileUrl(url), '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
const a = document.createElement('a')
a.href = fullFileUrl(url)
a.download = String(file.fileName || 'download')
a.target = '_blank'
a.rel = 'noopener'
a.click()
}
async function loadData() {
loading.value = true
try {
const res = await fetchReceiptConfirmList({
page: pagination.page,
pageSize: pagination.pageSize
})
const params = { page: pagination.page, pageSize: pagination.pageSize }
const res =
activeTab.value === 'pending'
? await fetchReceiptConfirmList(params)
: await fetchReceiptConfirmedList(params)
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
@@ -99,18 +329,37 @@
}
}
function switchTab(key: string) {
if (activeTab.value === key) return
activeTab.value = key
pagination.page = 1
loadData()
}
function openConfirm(row: Record<string, unknown>) {
currentDetailId.value = String(row.id)
confirmAttachments.value = ''
confirmRow.value = row
financeRemark.value = ''
attachments.value = []
dialogVisible.value = true
}
function openViewConfirm(row: Record<string, unknown>) {
viewRow.value = row
viewDialogVisible.value = true
}
async function submitConfirm() {
if (!currentDetailId.value) return
if (attachments.value.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
await fetchReceiptConfirmSubmit(currentDetailId.value, {
financeConfirmAttachments: confirmAttachments.value || undefined
financeConfirmAttachments: financeAttachmentsToUrlString(attachments.value) || undefined,
financeRemark: financeRemark.value.trim() || undefined
})
dialogVisible.value = false
loadData()
@@ -123,3 +372,73 @@
loadData()
})
</script>
<style scoped lang="scss">
.region-tabs {
margin-bottom: 16px;
}
.region-tab-btn {
position: relative;
min-width: 108px;
padding: 0 12px;
:deep(.el-icon) {
display: none;
}
}
.region-tab-btn.el-button > :deep(span) {
position: static !important;
display: inline-flex;
width: 100%;
justify-content: center;
}
.region-tab-inner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.payment-confirm-table-wrap {
overflow-x: auto;
}
.payment-confirm-dialog {
:deep(.el-dialog__body) {
padding-top: 16px;
}
}
.form-section-divider {
height: 1px;
margin: 20px 0;
background: var(--mint-border, #e2e8f0);
}
.detail-section-title {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-confirm-form :deep(.el-form-item) {
margin-bottom: 0;
}
.view-field-text {
display: block;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
&--multiline {
white-space: pre-wrap;
}
}
</style>
@@ -0,0 +1,888 @@
<!-- 缴费信息 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>缴费信息</h1>
<p class="page-desc">查看采集协议单缴费明细,按状态分为待缴费、缴费确认中、已缴费、退费确认中、已退费。</p>
</header>
<div class="region-tabs region-tab-strip">
<div class="tab-button-group region-tab-group">
<el-button
v-for="tab in tabs"
:key="tab.key"
class="region-tab-btn"
:type="activeTab === tab.key ? 'primary' : 'default'"
@click="switchTab(tab.key)"
>
<span class="region-tab-inner">
<span class="region-tab-name">{{ tab.label }}</span>
</span>
</el-button>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">缴费信息列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" label-position="top" @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.transferType" placeholder="全部" clearable>
<el-option label="全部" value="" />
<el-option label="微信转账" value="wxzz" />
<el-option label="支付宝转账" value="zzbzz" />
<el-option label="银行卡转账" value="yhkzz" />
<el-option label="现金" value="xj" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">支持按协议号模糊检索。</span>
<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
v-if="activeTab === 'dfj' || activeTab === 'yjf'"
class="list-toolbar"
>
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
class="btn-main"
type="primary"
:disabled="!selectedRows.length"
@click="handleBatchSubmitFinance"
>
{{ activeTab === 'dfj' ? '批量缴费确认' : '批量退费确认' }}
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column
v-if="activeTab === 'dfj' || activeTab === 'yjf'"
type="selection"
width="55"
align="center"
header-align="center"
/>
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="客户姓名"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.motherName || '-' }}
</template>
</el-table-column>
<el-table-column
prop="motherIdCard"
label="身份证号"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ maskIdCard(row.motherIdCard) }}
</template>
</el-table-column>
<el-table-column
prop="newbornName"
label="新生儿姓名"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.newbornName || '-' }}
</template>
</el-table-column>
<el-table-column
prop="newbornIdCard"
label="新生儿身份证号"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ maskIdCard(row.newbornIdCard) }}
</template>
</el-table-column>
<el-table-column
prop="protocolStatus"
label="协议状态"
width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ protocolStatusLabel(String(row.protocolStatus || '')) }}
</template>
</el-table-column>
<el-table-column
prop="payMode"
label="缴费方式"
width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ payModeLabel(String(row.payMode || '')) }}
</template>
</el-table-column>
<el-table-column
prop="installmentNo"
label="期数"
width="80"
align="center"
header-align="center"
/>
<el-table-column
prop="installmentDueDate"
label="应缴费时间"
width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ formatDate(String(row.installmentDueDate || '')) }}
</template>
</el-table-column>
<el-table-column
prop="dueAmount"
label="应缴金额(元)"
width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
<span class="amount-text">{{ formatAmount(row.dueAmount) }}</span>
</template>
</el-table-column>
<el-table-column
prop="transferAmount"
label="实缴金额(元)"
width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
<span class="amount-text">{{ formatAmount(row.transferAmount) }}</span>
</template>
</el-table-column>
<el-table-column
prop="transferType"
label="转账方式"
width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ transferTypeLabel(String(row.transferType || '')) }}
</template>
</el-table-column>
<el-table-column
prop="transferTime"
label="转账时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.transferTime || '-' }}
</template>
</el-table-column>
<el-table-column
prop="payStatus"
label="状态"
width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="payStatusTagType(String(row.payStatus || ''))" size="small">
{{ payStatusLabel(String(row.payStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
:min-width="activeTab === 'dfj' || activeTab === 'yjf' ? 280 : 120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="activeTab === 'dfj'"
class="btn-op-item primary"
@click="handleSubmitReceiptFinance(row)"
>
缴费确认
</el-button>
<el-button
v-if="activeTab === 'yjf'"
class="btn-op-item primary"
@click="openRefundFinanceDialog(row)"
>
退费确认
</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="refundDialogVisible"
title="退费确认"
width="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="refundFormRef"
:model="refundForm"
:rules="refundFormRules"
label-position="top"
class="biz-dialog-form"
>
<div class="form-grid-2">
<el-form-item label="退费金额(元)" prop="refundAmount" class="form-col-span-2">
<el-input-number
v-model="refundForm.refundAmount"
:min="0.01"
:precision="2"
:step="100"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="退费备注" prop="refundRemark" class="form-col-span-2">
<el-input
v-model="refundForm.refundRemark"
type="textarea"
:rows="3"
placeholder="选填"
/>
</el-form-item>
<el-form-item label="退费附件" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleRefundAttachmentChange"
>
<div class="upload-card">
<el-icon :size="22" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in refundForm.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 attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<el-icon class="action-btn" title="删除" @click="removeRefundAttachment(fIdx)">
<Delete />
</el-icon>
</div>
</div>
</div>
<p class="form-tip">支持图片、文档等,多个附件将一并提交。</p>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="refundDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="refundSubmitLoading"
@click="submitRefundFinance"
>
<el-icon><Check /></el-icon>
确认提交
</el-button>
</template>
</el-dialog>
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
RefreshLeft,
Search,
View,
Plus,
Document,
Delete,
Close,
Check
} from '@element-plus/icons-vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import {
fetchPaymentDetailList,
fetchSubmitReceiptFinance,
fetchSubmitReceiptFinanceBatch,
fetchSubmitRefundFinance,
fetchSubmitRefundFinanceBatch,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import { fetchUploadAttachment } from '@/api/business'
import { maskIdCard } from '@/utils/mask-sensitive'
defineOptions({ name: 'CellStoragePaymentInfo' })
const tabs = [
{ key: 'dfj', label: '待缴费' },
{ key: 'jfqrz', label: '缴费确认中' },
{ key: 'yjf', label: '已缴费' },
{ key: 'tfqrz', label: '退费确认中' },
{ key: 'ytf', label: '已退费' }
]
const activeTab = ref('dfj')
const searchForm = reactive({ keyword: '', transferType: '' })
const tableData = ref<Record<string, unknown>[]>([])
const selectedRows = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
const refundDialogVisible = ref(false)
const refundSubmitLoading = ref(false)
const refundTargetIds = ref<string[]>([])
const refundFormRef = ref<FormInstance>()
const refundForm = reactive({
refundAmount: null as number | null,
refundRemark: '',
attachments: [] as Array<{
id: string
fileName: string
fileUrl: string
_uploading?: boolean
}>
})
const refundFormRules: FormRules = {
refundAmount: [{ required: true, message: '请填写退费金额', trigger: 'blur' }]
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function handleSelectionChange(rows: Record<string, unknown>[]) {
selectedRows.value = rows
}
function handleView(row: Record<string, unknown>) {
const pid = String(row.collectionProtocolId || '')
if (!pid) {
ElMessage.warning('缺少关联协议单,无法查看')
return
}
viewProtocolId.value = pid
viewRow.value = {
id: pid,
protocolNo: row.protocolNo,
protocolStatus: row.protocolStatus,
motherName: row.motherName,
motherIdCard: row.motherIdCard,
newbornName: row.newbornName,
newbornIdCard: row.newbornIdCard
}
viewVisible.value = true
}
function protocolStatusLabel(s: string) {
const map: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
ygq: '已过期',
yzf: '已作废',
zky: '转科研'
}
return map[s] || s || '-'
}
function payModeLabel(s: string) {
if (s === 'dj') return '趸交'
if (s === 'fq') return '分期'
return s || '-'
}
function payStatusLabel(s: string) {
const map: Record<string, string> = {
dfj: '待缴费',
jfqrz: '缴费确认中',
yjf: '已缴费',
tfqrz: '退费确认中',
ytf: '已退费'
}
return map[s] || '未知'
}
function payStatusTagType(s: string) {
const map: Record<string, string> = {
dfj: 'info',
jfqrz: 'warning',
yjf: 'success',
tfqrz: 'warning',
ytf: 'danger'
}
return (map[s] || 'info') as 'info' | 'warning' | 'success' | 'danger'
}
function transferTypeLabel(t: string) {
const map: Record<string, string> = {
wxzz: '微信转账',
zzbzz: '支付宝转账',
yhkzz: '银行卡转账',
xj: '现金'
}
return map[t] || t || '-'
}
function formatDate(d: string) {
if (!d || d.length !== 8) return d || '-'
return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
}
function formatAmount(v: unknown) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
async function loadData() {
loading.value = true
try {
const res = await fetchPaymentDetailList({
page: pagination.page,
pageSize: pagination.pageSize,
payStatus: activeTab.value,
keyword: searchForm.keyword || undefined,
transferType: searchForm.transferType || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function switchTab(key: string) {
if (activeTab.value === key) return
activeTab.value = key
selectedRows.value = []
pagination.page = 1
loadData()
}
async function handleSubmitReceiptFinance(row: Record<string, unknown>) {
const id = String(row.id || '')
if (!id) return
try {
await ElMessageBox.confirm('确认对该缴费明细进行缴费确认?提交后状态变为「缴费确认中」。', '缴费确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await fetchSubmitReceiptFinance(id)
loadData()
} catch {
/* cancel */
}
}
async function handleBatchSubmitFinance() {
if (!selectedRows.value.length) return
const ids = selectedRows.value.map((r) => String(r.id || '')).filter(Boolean)
if (!ids.length) return
if (activeTab.value === 'dfj') {
try {
await ElMessageBox.confirm(
`确认将选中的 ${ids.length} 条待缴费明细批量缴费确认?`,
'批量缴费确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
await fetchSubmitReceiptFinanceBatch(ids)
selectedRows.value = []
loadData()
} catch {
/* cancel */
}
return
}
if (activeTab.value === 'yjf') {
openRefundFinanceDialog(undefined, ids)
}
}
function resetRefundForm(defaultAmount?: number) {
refundForm.refundAmount = defaultAmount ?? null
refundForm.refundRemark = ''
refundForm.attachments = []
refundFormRef.value?.clearValidate()
}
function openRefundFinanceDialog(
row?: Record<string, unknown>,
batchIds?: string[]
) {
const ids = batchIds?.length
? batchIds
: row?.id
? [String(row.id)]
: []
if (!ids.length) return
refundTargetIds.value = ids
const defaultAmount =
!batchIds?.length && row?.transferAmount != null
? Number(row.transferAmount)
: undefined
resetRefundForm(
defaultAmount != null && !Number.isNaN(defaultAmount) ? defaultAmount : undefined
)
refundDialogVisible.value = true
}
function refundAttachmentsToUrls() {
return refundForm.attachments
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => f.fileUrl)
.join(',')
}
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)
}
async function handleRefundAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
const maxSizeMb = 20
if (file.size / 1024 / 1024 > maxSizeMb) {
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
return
}
const tempId = `temp_${Date.now()}`
refundForm.attachments.push({
id: tempId,
fileName: file.name,
fileUrl: '',
_uploading: true
})
const idx = refundForm.attachments.length - 1
try {
const fd = new FormData()
fd.append('folder', 'protocol-payment-refund')
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
refundForm.attachments[idx] = {
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
_uploading: false
}
} catch {
ElMessage.error('文件上传失败')
refundForm.attachments.splice(idx, 1)
}
}
function removeRefundAttachment(index: number) {
refundForm.attachments.splice(index, 1)
}
async function submitRefundFinance() {
if (!refundTargetIds.value.length) return
const valid = await refundFormRef.value?.validate().catch(() => false)
if (!valid) return
if (refundForm.attachments.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
const payload = {
refundAmount: Number(refundForm.refundAmount),
refundRemark: refundForm.refundRemark?.trim() || undefined,
refundAttachments: refundAttachmentsToUrls() || undefined
}
refundSubmitLoading.value = true
try {
if (refundTargetIds.value.length === 1) {
await fetchSubmitRefundFinance(refundTargetIds.value[0], payload)
} else {
await fetchSubmitRefundFinanceBatch({
detailIds: refundTargetIds.value,
...payload
})
}
refundDialogVisible.value = false
selectedRows.value = []
loadData()
} finally {
refundSubmitLoading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.transferType = ''
pagination.page = 1
loadData()
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.region-tabs {
margin-bottom: 16px;
}
.region-tab-btn {
position: relative;
min-width: 108px;
padding: 0 12px;
:deep(.el-icon) {
display: none;
}
}
.region-tab-btn.el-button > :deep(span) {
position: static !important;
display: inline-flex;
width: 100%;
justify-content: center;
}
.region-tab-inner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
}
.filter-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
padding: 0 0 4px;
line-height: 1.2;
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card {
width: 120px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
&:hover {
border-color: #10b981;
background: #ecfdf5;
}
}
.upload-card-text {
font-size: 12px;
color: #10b981;
font-weight: 500;
}
.attachment-item {
width: 120px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
background: #fff;
&.is-uploading {
opacity: 0.6;
pointer-events: none;
}
}
.attachment-preview {
height: 96px;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.attachment-preview__icon--doc {
color: #64748b;
}
.attachment-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 6px 8px;
font-size: 12px;
}
.attachment-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-btn {
cursor: pointer;
color: #94a3b8;
&:hover {
color: #ef4444;
}
}
</style>
@@ -908,7 +908,7 @@
<el-option label="已退费" value="ytf" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.payMode === 'fq'" label="应缴费时间">
<el-form-item label="应缴费时间">
<el-date-picker
v-model="detail.installmentDueDate"
type="date"
@@ -1426,7 +1426,7 @@
if (detail.payStatus) {
parts.push(payStatusLabel(String(detail.payStatus)))
}
if (formData.payMode === 'fq' && detail.installmentDueDate) {
if (detail.installmentDueDate) {
parts.push(`应缴日 ${formatPaymentDateYmd(String(detail.installmentDueDate))}`)
}
const attCount = Array.isArray(detail.attachments) ? detail.attachments.length : 0
@@ -1682,13 +1682,15 @@
const pad = (n: number) => String(n).padStart(2, '0')
const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const isFirst = formData.paymentDetails.length === 0
// 趸交模式下应缴费时间默认等于趸交截止时间
const defaultDueDate = formData.payMode === 'dj' ? formData.lumpSumDeadline : ''
formData.paymentDetails.push({
transferType: '',
dueAmount: null,
transferAmount: null,
transferTime: defaultTime,
payStatus: 'dfj',
installmentDueDate: '',
installmentDueDate: defaultDueDate,
_expanded: isFirst,
attachments: [] as Array<{
id?: string
@@ -1754,10 +1756,21 @@
if (val === 'fq') {
tryGenerateInstallmentDetails()
} else {
// 趸交模式:将趸交截止时间同步到所有明细的应缴费时间
syncPaymentDueAmounts()
syncLumpSumDueDate()
}
}
/** 趸交模式:将趸交截止时间同步到所有缴费明细的应缴费时间 */
function syncLumpSumDueDate() {
if (formData.payMode !== 'dj') return
const deadline = formData.lumpSumDeadline || ''
formData.paymentDetails.forEach((d) => {
d.installmentDueDate = deadline
})
}
function handleOpenForAdd() {
isEdit.value = false
dialogTitle.value = '新增协议单'
@@ -2621,6 +2634,16 @@
() => calcBirthFromIdCard()
)
/* ====== 趸交截止时间联动 ====== */
watch(
() => formData.lumpSumDeadline,
() => {
if (formData.payMode === 'dj') {
syncLumpSumDueDate()
}
}
)
/* ====== 附件上传 ====== */
const attachmentUploadRef = ref()
@@ -313,6 +313,56 @@
/>
</div>
</dl>
<template
v-for="flowCtx in [{ nodes: buildPaymentFlowNodes(detail) }]"
:key="`${detail.id || dIdx}-flow`"
>
<div v-if="flowCtx.nodes.length" class="payment-flow">
<div class="payment-flow-scroll">
<div class="payment-flow-track">
<div
v-for="(node, nIdx) in flowCtx.nodes"
:key="node.key"
class="payment-flow-item"
>
<div class="payment-flow-rail">
<span class="payment-flow-node" />
<span
v-if="nIdx < flowCtx.nodes.length - 1"
class="payment-flow-line"
/>
</div>
<div class="payment-flow-body">
<div class="payment-flow-title">{{ node.title }}</div>
<div
v-if="node.amount != null && node.amount !== ''"
class="payment-flow-kv"
>
<span class="payment-flow-kv__label">退费金额(元)</span>
<span class="payment-flow-kv__value">{{ dash(node.amount) }}</span>
</div>
<div
v-if="node.remark != null && String(node.remark).trim() !== ''"
class="payment-flow-kv payment-flow-kv--remark"
>
<span class="payment-flow-kv__label">{{ node.remarkLabel }}</span>
<span class="payment-flow-kv__value">{{ dash(node.remark) }}</span>
</div>
<div v-if="node.attachments.length" class="payment-flow-attachments">
<span class="payment-flow-kv__label">{{ node.attachmentLabel }}</span>
<ProtocolAttachmentReadonly
:files="node.attachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
@@ -494,6 +544,8 @@
const collectorDisplay = computed(() => {
const fromList = props.listRow?.collectorUserName
if (fromList) return String(fromList)
const fromBundle = protocol.value?.collectorUserName
if (fromBundle) return String(fromBundle)
const id = protocol.value?.collectorUserId
const u = collectorUserOptions.value.find((x) => x.id === id)
return u?.nickName || dash(id)
@@ -502,6 +554,8 @@
const salesDisplay = computed(() => {
const fromList = props.listRow?.salesUserName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.salesUserName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.salesUserId
const u = salesUserOptions.value.find((x) => x.id === id)
return u?.nickName || dash(id)
@@ -510,6 +564,8 @@
const regionDisplay = computed(() => {
const fromList = props.listRow?.regionName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.regionName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.regionId
const r = regionOptions.value.find((x) => x.id === id)
return r?.regionName || dash(id)
@@ -518,6 +574,8 @@
const hospitalDisplay = computed(() => {
const fromList = props.listRow?.hospitalName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.hospitalName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.hospitalId
const h = hospitalOptions.value.find((x) => x.id === id)
return h?.hospitalName || dash(id)
@@ -649,6 +707,131 @@
return Array.isArray(detail.attachments) ? detail.attachments : []
}
function financeConfirmAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.financeConfirmAttachmentFiles)) {
return detail.financeConfirmAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.financeConfirmAttachments || ''))
}
function refundAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.refundAttachmentFiles)) {
return detail.refundAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.refundAttachments || ''))
}
function refundFinanceConfirmAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.refundFinanceConfirmAttachmentFiles)) {
return detail.refundFinanceConfirmAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.refundFinanceConfirmAttachments || ''))
}
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, any>>
}
function hasFinanceRemark(detail: Record<string, any>) {
return String(detail.financeRemark || '').trim() !== ''
}
function hasFinanceConfirmAttachments(detail: Record<string, any>) {
return financeConfirmAttachmentFiles(detail).length > 0
}
function hasRefundApplyInfo(detail: Record<string, any>) {
const amount = detail.refundAmount
const hasAmount = amount != null && amount !== '' && Number(amount) > 0
return (
hasAmount ||
String(detail.refundRemark || '').trim() !== '' ||
refundAttachmentFiles(detail).length > 0
)
}
/** 缴费财务确认:已缴费或退费确认中,且已有财务确认字段 */
function showPaymentFinanceConfirm(detail: Record<string, any>) {
const status = String(detail.payStatus || '')
if (status === 'ytf') return false
if (status !== 'yjf' && status !== 'tfqrz') return false
return hasFinanceRemark(detail) || hasFinanceConfirmAttachments(detail)
}
/** 退费确认申请:发起退费时填写 */
function showRefundApply(detail: Record<string, any>) {
const status = String(detail.payStatus || '')
if (status !== 'tfqrz' && status !== 'ytf') return false
return hasRefundApplyInfo(detail)
}
function hasRefundFinanceRemark(detail: Record<string, any>) {
return String(detail.refundFinanceRemark || '').trim() !== ''
}
function hasRefundFinanceConfirmAttachments(detail: Record<string, any>) {
return refundFinanceConfirmAttachmentFiles(detail).length > 0
}
/** 财务退费确认:已退费时展示的财务确认信息 */
function showRefundFinanceConfirm(detail: Record<string, any>) {
if (String(detail.payStatus || '') !== 'ytf') return false
return hasRefundFinanceRemark(detail) || hasRefundFinanceConfirmAttachments(detail)
}
interface PaymentFlowNode {
key: string
title: string
remarkLabel: string
attachmentLabel: string
remark?: unknown
amount?: unknown
attachments: Array<Record<string, any>>
}
/** 缴费明细 — 财务/退费流程横向节点(按业务顺序) */
function buildPaymentFlowNodes(detail: Record<string, any>): PaymentFlowNode[] {
const nodes: PaymentFlowNode[] = []
if (showPaymentFinanceConfirm(detail)) {
nodes.push({
key: 'receipt-finance',
title: '缴费财务确认',
remarkLabel: '财务备注',
attachmentLabel: '财务附件',
remark: detail.financeRemark,
attachments: financeConfirmAttachmentFiles(detail)
})
}
if (showRefundApply(detail)) {
nodes.push({
key: 'refund-apply',
title: '退费确认申请',
remarkLabel: '退费备注',
attachmentLabel: '退费附件',
remark: detail.refundRemark,
amount: detail.refundAmount,
attachments: refundAttachmentFiles(detail)
})
}
if (showRefundFinanceConfirm(detail)) {
nodes.push({
key: 'refund-finance',
title: '财务退费确认',
remarkLabel: '财务备注',
attachmentLabel: '财务附件',
remark: detail.refundFinanceRemark,
attachments: refundFinanceConfirmAttachmentFiles(detail)
})
}
return nodes
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
@@ -947,11 +1130,133 @@
}
.payment-detail-card__attachments {
margin-top: 8px;
padding-top: 8px;
margin-top: 4px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
.payment-flow {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.payment-flow-scroll {
overflow-x: auto;
padding: 0 0 6px 4px;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 2px;
}
}
.payment-flow-track {
display: inline-flex;
min-width: 100%;
align-items: flex-start;
}
.payment-flow-item {
display: flex;
flex-direction: column;
min-width: 200px;
flex: 1 0 200px;
max-width: 280px;
}
.payment-flow-rail {
display: flex;
align-items: center;
height: 22px;
}
.payment-flow-node {
position: relative;
z-index: 1;
flex-shrink: 0;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-radius: 50%;
background: var(--primary, #10b981);
box-shadow: 0 0 0 1px var(--primary, #10b981);
}
.payment-flow-line {
flex: 1;
height: 2px;
margin-left: 0;
background: #d1d5db;
}
.payment-flow-body {
padding: 8px 12px 0 0;
font-size: 13px;
line-height: 1.45;
}
.payment-flow-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.payment-flow-kv {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 8px;
}
.payment-flow-kv--remark .payment-flow-kv__value {
word-break: break-word;
}
.payment-flow-kv__label {
font-size: 11px;
font-weight: 500;
color: #94a3b8;
}
.payment-flow-kv__value {
color: #1e293b;
}
.payment-flow-attachments {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
:deep(.attachment-list) {
flex-wrap: nowrap;
gap: 8px;
}
:deep(.attachment-item) {
width: 88px;
flex-shrink: 0;
}
:deep(.attachment-preview) {
height: 64px;
}
:deep(.attachment-footer) {
padding: 4px 6px;
}
:deep(.attachment-name) {
font-size: 11px;
}
}
.view-field-block {
display: flex;
flex-direction: column;
@@ -248,7 +248,12 @@
/>
<!-- 查看弹窗 -->
<ProtocolViewDialog v-model="viewVisible" :protocol-id="viewProtocolId" :list-row="viewRow" />
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
/>
</section>
</div>
</template>
@@ -4,27 +4,142 @@
<div class="page-container">
<header class="page-head">
<h1>退费确认</h1>
<p class="page-desc">仅处理「退费确认中」明细,确认后变为已退费。</p>
<p class="page-desc">
待确认:处理「退费确认中」明细;已确认:查看财务确认退费记录及当时填写的备注与附件。
</p>
</header>
<div class="region-tabs region-tab-strip">
<div class="tab-button-group region-tab-group">
<el-button
v-for="tab in tabs"
:key="tab.key"
class="region-tab-btn"
:type="activeTab === tab.key ? 'primary' : 'default'"
@click="switchTab(tab.key)"
>
<span class="region-tab-inner">
<span class="region-tab-name">{{ tab.label }}</span>
</span>
</el-button>
</div>
</div>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">退费确认列表</h2>
</div>
<div class="list-body list-wrap">
<div class="list-body list-wrap refund-confirm-table-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe row-key="id">
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="protocolNo" label="协议号" min-width="130" align="center" header-align="center" />
<el-table-column prop="installmentNo" label="期数" width="80" align="center" header-align="center" />
<el-table-column prop="transferAmount" label="金额" width="120" align="center" header-align="center" />
<el-table-column prop="payStatus" label="状态" width="120" align="center" header-align="center">
<template #default>退费确认中</template>
<el-table-column prop="motherName" label="客户姓名" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.motherName || '-' }}</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
<el-table-column prop="motherIdCard" label="身份证号" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ maskIdCard(row.motherIdCard) }}</template>
</el-table-column>
<el-table-column prop="newbornName" label="新生儿姓名" min-width="110" align="center" header-align="center">
<template #default="{ row }">{{ row.newbornName || '-' }}</template>
</el-table-column>
<el-table-column
prop="newbornIdCard"
label="新生儿身份证号"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ maskIdCard(row.newbornIdCard) }}</template>
</el-table-column>
<el-table-column prop="protocolStatus" label="协议状态" width="100" align="center" header-align="center">
<template #default="{ row }">
{{ protocolStatusLabel(String(row.protocolStatus || '')) }}
</template>
</el-table-column>
<el-table-column prop="payMode" label="缴费方式" width="100" align="center" header-align="center">
<template #default="{ row }">{{ payModeLabel(String(row.payMode || '')) }}</template>
</el-table-column>
<el-table-column prop="installmentNo" label="期数" width="80" align="center" header-align="center">
<template #default="{ row }">{{ row.installmentNo ?? '-' }}</template>
</el-table-column>
<el-table-column prop="installmentDueDate" label="应缴费时间" width="120" align="center" header-align="center">
<template #default="{ row }">{{ formatPaymentDueDate(row.installmentDueDate) }}</template>
</el-table-column>
<el-table-column prop="dueAmount" label="应缴金额(元)" width="120" align="center" header-align="center">
<template #default="{ row }">{{ formatPaymentAmount(row.dueAmount) }}</template>
</el-table-column>
<el-table-column prop="transferAmount" label="实缴金额(元)" width="120" align="center" header-align="center">
<template #default="{ row }">{{ formatPaymentAmount(row.transferAmount) }}</template>
</el-table-column>
<el-table-column prop="transferType" label="转账方式" width="110" align="center" header-align="center">
<template #default="{ row }">{{ transferTypeLabel(String(row.transferType || '')) }}</template>
</el-table-column>
<el-table-column prop="transferTime" label="转账时间" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ row.transferTime || '-' }}</template>
</el-table-column>
<el-table-column prop="refundAmount" label="退费金额(元)" width="120" align="center" header-align="center">
<template #default="{ row }">{{ formatPaymentAmount(row.refundAmount) }}</template>
</el-table-column>
<el-table-column
prop="refundRemark"
label="退费备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.refundRemark || '-' }}</template>
</el-table-column>
<el-table-column prop="payStatus" label="状态" width="110" align="center" header-align="center">
<template #default>
{{ activeTab === 'pending' ? '退费确认中' : '已退费' }}
</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="updateTime"
label="确认时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="refundFinanceRemark"
label="财务备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.refundFinanceRemark || '-' }}</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'pending'"
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column label="操作" width="140" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openConfirm(row)">确认退费</el-button>
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
@click="openConfirm(row)"
>
确认退费
</el-button>
<el-button v-else class="btn-op-item" @click="openViewConfirm(row)">查看确认信息</el-button>
</template>
</el-table-column>
</el-table>
@@ -44,15 +159,28 @@
<el-dialog
v-model="dialogVisible"
title="确认退费"
width="560px"
class="dialog-form-layout"
width="900px"
class="dialog-form-layout refund-confirm-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<ProtocolPaymentDetailReadonly
:detail="confirmRow"
title="缴费明细(请核对)"
show-refund-apply
refund-apply-title="退费申请(请核对)"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认</h4>
<el-form label-position="top" class="biz-dialog-form">
<el-form-item label="财务确认附件(MinIO 地址,逗号分隔,可选)">
<el-input v-model="confirmAttachments" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
<ProtocolFinanceConfirmFields
v-model:finance-remark="financeRemark"
v-model:attachments="attachments"
upload-folder="protocol-finance-refund"
/>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
@@ -65,32 +193,152 @@
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="viewDialogVisible"
title="确认退费信息"
width="900px"
class="dialog-form-layout refund-confirm-dialog"
align-center
destroy-on-close
>
<ProtocolPaymentDetailReadonly
:detail="viewRow"
title="缴费明细"
show-refund-apply
refund-apply-title="退费申请"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认信息</h4>
<el-form label-position="top" class="biz-dialog-form view-confirm-form">
<div class="form-grid-2">
<el-form-item label="确认时间" class="form-col-span-2">
<span class="view-field-text">{{ viewRow?.updateTime || '-' }}</span>
</el-form-item>
<el-form-item label="财务备注" class="form-col-span-2">
<span class="view-field-text view-field-text--multiline">{{
viewRow?.refundFinanceRemark || '—'
}}</span>
</el-form-item>
<el-form-item label="财务确认附件" class="form-col-span-2">
<ProtocolAttachmentReadonly
:files="viewFinanceAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="viewDialogVisible = false">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check } from '@element-plus/icons-vue'
import { fetchRefundConfirmList, fetchRefundConfirmSubmit } from '@/api/collection-protocol'
import {
fetchRefundConfirmList,
fetchRefundConfirmedList,
fetchRefundConfirmSubmit
} from '@/api/collection-protocol'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolPaymentDetailReadonly from '@/components/business/protocol-payment-detail-readonly/index.vue'
import ProtocolAttachmentReadonly from '../protocol/ProtocolAttachmentReadonly.vue'
import {
financeAttachmentsToUrlString,
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { maskIdCard } from '@/utils/mask-sensitive'
import {
formatPaymentAmount,
formatPaymentDueDate,
payModeLabel,
protocolStatusLabel,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
defineOptions({ name: 'CellStorageRefundConfirm' })
const tabs = [
{ key: 'pending', label: '待确认' },
{ key: 'confirmed', label: '已确认' }
]
const activeTab = ref('pending')
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const viewDialogVisible = ref(false)
const submitLoading = ref(false)
const confirmAttachments = ref('')
const financeRemark = ref('')
const attachments = ref<FinanceAttachmentItem[]>([])
const currentDetailId = ref<string | null>(null)
const confirmRow = ref<Record<string, unknown> | null>(null)
const viewRow = ref<Record<string, unknown> | null>(null)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const viewFinanceAttachmentFiles = computed(() => {
const row = viewRow.value
if (!row) return []
if (Array.isArray(row.refundFinanceConfirmAttachmentFiles)) {
return row.refundFinanceConfirmAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(row.refundFinanceConfirmAttachments || '')
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>>
})
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
window.open(fullFileUrl(url), '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
const a = document.createElement('a')
a.href = fullFileUrl(url)
a.download = String(file.fileName || 'download')
a.target = '_blank'
a.rel = 'noopener'
a.click()
}
async function loadData() {
loading.value = true
try {
const res = await fetchRefundConfirmList({
page: pagination.page,
pageSize: pagination.pageSize
})
const params = { page: pagination.page, pageSize: pagination.pageSize }
const res =
activeTab.value === 'pending'
? await fetchRefundConfirmList(params)
: await fetchRefundConfirmedList(params)
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
@@ -98,18 +346,37 @@
}
}
function switchTab(key: string) {
if (activeTab.value === key) return
activeTab.value = key
pagination.page = 1
loadData()
}
function openConfirm(row: Record<string, unknown>) {
currentDetailId.value = String(row.id)
confirmAttachments.value = ''
confirmRow.value = row
financeRemark.value = ''
attachments.value = []
dialogVisible.value = true
}
function openViewConfirm(row: Record<string, unknown>) {
viewRow.value = row
viewDialogVisible.value = true
}
async function submitConfirm() {
if (!currentDetailId.value) return
if (attachments.value.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
await fetchRefundConfirmSubmit(currentDetailId.value, {
financeConfirmAttachments: confirmAttachments.value || undefined
refundFinanceConfirmAttachments: financeAttachmentsToUrlString(attachments.value) || undefined,
refundFinanceRemark: financeRemark.value.trim() || undefined
})
dialogVisible.value = false
loadData()
@@ -122,3 +389,73 @@
loadData()
})
</script>
<style scoped lang="scss">
.region-tabs {
margin-bottom: 16px;
}
.region-tab-btn {
position: relative;
min-width: 108px;
padding: 0 12px;
:deep(.el-icon) {
display: none;
}
}
.region-tab-btn.el-button > :deep(span) {
position: static !important;
display: inline-flex;
width: 100%;
justify-content: center;
}
.region-tab-inner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.refund-confirm-table-wrap {
overflow-x: auto;
}
.refund-confirm-dialog {
:deep(.el-dialog__body) {
padding-top: 16px;
}
}
.form-section-divider {
height: 1px;
margin: 20px 0;
background: var(--mint-border, #e2e8f0);
}
.detail-section-title {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-confirm-form :deep(.el-form-item) {
margin-bottom: 0;
}
.view-field-text {
display: block;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
&--multiline {
white-space: pre-wrap;
}
}
</style>