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

@@ -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>