优化backend报销管理模块

This commit is contained in:
2026-08-13 17:08:38 +08:00
parent b45152599e
commit f818ecb74f
24 changed files with 2460 additions and 299 deletions
@@ -244,7 +244,7 @@ onUnmounted(() => {
<style lang="less" scoped>
.erp-dashboard {
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
// min-height: calc(100vh - 84px);
}
.stat-row {
@@ -1,11 +1,9 @@
<template>
<el-dialog v-model="visible" title="报销详情" width="980px" destroy-on-close>
<el-descriptions v-if="detail" :column="3" border>
<el-descriptions-item label="报销事由" :span="2">{{ detail.title }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ detail.expense_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请日期">{{ detail.apply_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="合计金额">¥ {{ money(detail.total_amount) }}</el-descriptions-item>
<el-descriptions-item label="备注说明" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
@@ -13,7 +11,9 @@
<div class="section-header">
<h4 class="detail-title">费用明细</h4>
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
<div class="section-actions" style="margin-bottom: 12px;">
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
</div>
</div>
<el-table :data="detail?.items || []" border size="small">
@@ -22,15 +22,17 @@
<el-table-column prop="amount" label="金额" width="110">
<template #default="{ row }">¥ {{ money(row.amount) }}</template>
</el-table-column>
<el-table-column prop="receipt_flag" label="发票" width="70">
<template #default="{ row }">{{ row.receipt_flag ? '有' : '无' }}</template>
<el-table-column prop="receipt_flag" label="发票" width="90">
<template #default="{ row }">
{{ itemHasReceipt(row) ? '有' : '无' }}({{ getItemReceiptFiles(row).length }})
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
<template #default="{ row }">
<div class="item-actions">
<el-button
v-if="row.receipt_url"
v-if="itemHasReceipt(row)"
link
type="primary"
title="查看票据"
@@ -40,24 +42,18 @@
<i class="fa-solid fa-eye"></i>
</el-button>
<template v-if="isDraft">
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/*,.pdf"
:on-change="file => uploadInvoice(row, file)"
:disabled="uploadingItemId === row.id"
<el-button
link
type="success"
title="上传发票(可多选,追加到当前费用项)"
aria-label="上传发票"
:disabled="batchUploading"
@click="openInvoiceFilePicker(row)"
>
<el-button
link
type="success"
title="上传发票"
aria-label="上传发票"
>
<i
:class="uploadingItemId === row.id ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
></i>
</el-button>
</el-upload>
<i
:class="batchUploading ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
></i>
</el-button>
<el-button link type="primary" title="编辑" aria-label="编辑" @click="openEditItem(row)">
<i class="fa-solid fa-pen-to-square"></i>
</el-button>
@@ -71,11 +67,36 @@
<template #empty><el-empty description="暂无费用明细" :image-size="60" /></template>
</el-table>
<input
ref="invoiceFileInputRef"
type="file"
multiple
accept="image/*,.pdf,application/pdf"
class="invoice-file-input"
@change="handleInvoiceFileInputChange"
/>
<el-dialog v-model="receiptPreviewVisible" title="查看票据" width="980px" append-to-body destroy-on-close>
<div v-if="receiptList.length" class="receipt-preview">
<aside class="receipt-list">
<el-select
v-model="receiptTypeFilter"
clearable
placeholder="筛选费用类型"
size="small"
class="receipt-filter"
@change="handleReceiptFilterChange"
>
<el-option label="全部" value="" />
<el-option
v-for="type in receiptTypeOptions"
:key="type"
:label="type"
:value="type"
/>
</el-select>
<button
v-for="receipt in receiptList"
v-for="receipt in filteredReceiptList"
:key="receipt.id"
type="button"
class="receipt-thumb"
@@ -90,7 +111,7 @@
lazy
/>
<i v-else class="fa-regular fa-file-pdf receipt-thumb-pdf"></i>
<span>{{ receipt.name }}</span>
<span class="receipt-thumb-name" :title="receipt.name">{{ receipt.name }}</span>
<el-button
v-if="isDraft"
link
@@ -101,9 +122,10 @@
<i class="fa-solid fa-trash"></i>
</el-button>
</button>
<el-empty v-if="!filteredReceiptList.length" description="暂无匹配的票据" :image-size="48" />
</aside>
<main v-if="receiptPreviewUrl" class="receipt-viewer">
<main v-if="receiptPreviewUrl && filteredReceiptList.some(item => item.id === receiptPreviewItemId)" class="receipt-viewer">
<el-image
v-if="isReceiptImage"
:src="receiptPreviewUrl"
@@ -112,7 +134,11 @@
/>
<iframe v-else :src="receiptPreviewUrl" title="票据 PDF 预览" class="receipt-pdf"></iframe>
</main>
<main v-else class="receipt-viewer receipt-viewer-empty">
<el-empty description="请选择左侧票据查看" :image-size="80" />
</main>
</div>
<el-empty v-else description="暂无票据" :image-size="80" />
</el-dialog>
<h4 class="detail-title" style="margin-top: 20px;">审批记录</h4>
@@ -137,6 +163,45 @@
:expense-type-options="expenseTypeOptions"
@saved="handleItemsSaved"
/>
<el-dialog
v-model="manualConfirmVisible"
title="手动确认"
width="420px"
append-to-body
destroy-on-close
>
<p class="manual-tip">选择要变更的单据状态,保存后立即生效。</p>
<el-radio-group v-model="manualStatus" class="manual-status-group">
<el-radio
v-for="item in statusOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="manualConfirmVisible = false">取消</el-button>
<el-button type="primary" :loading="manualSaving" @click="saveManualStatus">确认</el-button>
</template>
</el-dialog>
<template #footer>
<div class="detail-toolbar">
<el-button @click="openManualConfirm">手动确认</el-button>
<div class="toolbar-right">
<el-button
v-if="isDraft"
type="primary"
:loading="submitting"
@click="handleSubmit"
>
提交审核
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
@@ -144,7 +209,7 @@
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadFile } from '@/api/file'
import { deleteReimbursementItem, updateReimbursementItem } from '@/api/reimburse'
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus } from '@/api/reimburse'
import ItemForm from './itemForm.vue'
const props = defineProps({
@@ -155,39 +220,101 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue', 'changed'])
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[value] || 'info'
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const isDraft = computed(() => props.detail?.status === 0)
const submitting = ref(false)
const manualConfirmVisible = ref(false)
const manualSaving = ref(false)
const manualStatus = ref(0)
const itemVisible = ref(false)
const uploadingItemId = ref(0)
const batchUploading = ref(false)
const invoiceFileInputRef = ref(null)
const pendingUploadRow = ref(null)
const editingItemId = ref(0)
const editingItem = ref(null)
const receiptPreviewVisible = ref(false)
const receiptPreviewUrl = ref('')
const receiptPreviewType = ref('')
const receiptPreviewItemId = ref(0)
const receiptPreviewItemId = ref('')
const receiptTypeFilter = ref('')
const getReceiptUrl = value => (value ? String(value).split(',').filter(Boolean)[0] || '' : '')
const getItemReceiptFiles = item => {
if (Array.isArray(item?.receipt_files) && item.receipt_files.length) {
return item.receipt_files
}
const ids = String(item?.receipt_file_ids || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
const urls = String(item?.receipt_urls || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
if (ids.length) {
return ids.map((id, index) => ({
file_id: Number(id),
url: urls[index] || urls[0] || '',
name: ''
}))
}
if (getReceiptUrl(item?.receipt_urls)) {
return [{ file_id: 0, url: item.receipt_urls, name: '' }]
}
return []
}
const itemHasReceipt = item => getItemReceiptFiles(item).length > 0
const receiptList = computed(() => {
const items = props.detail?.items || []
const list = []
items.forEach(item => {
if (!item.receipt_url) return
const urls = item.receipt_url.split(',').filter(Boolean)
urls.forEach((url, index) => {
const files = getItemReceiptFiles(item)
files.forEach((file, index) => {
const rawUrl = file.url || ''
if (!rawUrl) return
list.push({
id: `${item.id}-${index}`,
originalId: item.id, // 记录原始明细ID,用于删除时定位
url: resolveReceiptUrl(url),
isImage: /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(url || ''),
name: `${item.expense_type || '票据'} ${index + 1}`
id: `${item.id}-${file.file_id || index}`,
originalId: item.id,
fileId: file.file_id || 0,
expense_type: item.expense_type || '未分类',
url: resolveReceiptUrl(rawUrl),
rawUrl,
isImage: /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(rawUrl),
name: file.name || `${item.expense_type || '票据'} ${index + 1}`
})
})
})
return list
})
const receiptTypeOptions = computed(() => {
const types = new Set(receiptList.value.map(item => item.expense_type))
return Array.from(types).sort((a, b) => a.localeCompare(b, 'zh-CN'))
})
const filteredReceiptList = computed(() => {
if (!receiptTypeFilter.value) return receiptList.value
return receiptList.value.filter(item => item.expense_type === receiptTypeFilter.value)
})
const isReceiptImage = computed(() => receiptPreviewType.value.startsWith('image/'))
const resolveReceiptUrl = url => {
@@ -197,24 +324,32 @@ const resolveReceiptUrl = url => {
}
const selectReceipt = receipt => {
if (!receipt) return
receiptPreviewItemId.value = receipt.id
receiptPreviewUrl.value = receipt.url
receiptPreviewType.value = receipt.isImage ? 'image/*' : 'application/pdf'
}
const handleReceiptFilterChange = () => {
const current = filteredReceiptList.value.find(item => item.id === receiptPreviewItemId.value)
if (current) return
selectReceipt(filteredReceiptList.value[0] || null)
if (!filteredReceiptList.value.length) {
receiptPreviewUrl.value = ''
receiptPreviewType.value = ''
receiptPreviewItemId.value = ''
}
}
const openReceiptPreview = row => {
// 查找列表项,注意这里的 row.id 可能是原始记录ID,或者是多票据生成出的唯一ID
// 如果是从表格点击查看,row 对应的是明细项,其本身可能包含多个票据
// 我们直接找到该明细对应的第一张票据并打开预览窗口
const firstReceipt = receiptList.value.find(item => item.originalId === row.id)
if (!firstReceipt) {
const receipt = receiptList.value.find(item => item.originalId === row.id)
if (!receipt) {
ElMessage.warning('暂无票据可查看')
return
}
receiptTypeFilter.value = row.expense_type || ''
receiptPreviewVisible.value = true
selectReceipt(firstReceipt)
selectReceipt(receipt)
}
const openAddItem = () => {
@@ -233,83 +368,191 @@ const handleItemsSaved = reimbursementId => {
editingItem.value = null
emit('changed', reimbursementId)
}
const uploadInvoice = async (row, file) => {
if (!file?.raw) return
const rawFile = file.raw
const isAllowed = rawFile.type.startsWith('image/') || rawFile.type === 'application/pdf'
if (!isAllowed) {
ElMessage.error('发票仅支持图片或 PDF 文件')
return
}
uploadingItemId.value = row.id
const handleSubmit = async () => {
if (!props.detail?.id) return
try {
const formData = new FormData()
formData.append('file', rawFile)
formData.append('cate', 'reimbursement-invoice')
const uploadResponse = await uploadFile(formData)
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
throw new Error(uploadResponse?.msg || '发票上传失败')
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
submitting.value = true
await submitReimbursement(props.detail.id)
ElMessage.success('提交成功')
emit('changed', props.detail.id)
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error?.message || '提交失败')
}
} finally {
submitting.value = false
}
}
const newUrl = uploadResponse?.data?.url || uploadResponse?.url || ''
// 获取当前已有的 URL 列表
const existingUrls = row.receipt_url ? row.receipt_url.split(',').filter(Boolean) : []
existingUrls.push(newUrl)
const resolveManualStatus = status => (status >= 0 && status <= 5 ? status : 0)
await updateReimbursementItem(props.detail.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: 1,
receipt_url: existingUrls.join(',')
})
ElMessage.success('发票上传成功')
const openManualConfirm = () => {
manualStatus.value = resolveManualStatus(props.detail?.status ?? 0)
manualConfirmVisible.value = true
}
const saveManualStatus = async () => {
if (!props.detail?.id) return
manualSaving.value = true
try {
await updateReimbursementStatus(props.detail.id, { status: manualStatus.value })
ElMessage.success('状态已更新')
manualConfirmVisible.value = false
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '更新状态失败')
} finally {
manualSaving.value = false
}
}
const validateInvoiceFile = rawFile => {
if (!rawFile) return false
const name = rawFile.name.toLowerCase()
const byExt = /\.(jpe?g|png|gif|bmp|webp|pdf)$/i.test(name)
const byType = !rawFile.type || rawFile.type.startsWith('image/') || rawFile.type === 'application/pdf'
if (!byExt && !byType) {
ElMessage.error(`「${rawFile.name}」格式不支持,仅支持图片或 PDF`)
return false
}
return true
}
const uploadFileToSystem = async rawFile => {
const formData = new FormData()
formData.append('file', rawFile)
formData.append('cate', 'reimbursement-invoice')
const uploadResponse = await uploadFile(formData)
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
throw new Error(uploadResponse?.msg || `「${rawFile.name}」上传失败`)
}
const url = uploadResponse?.data?.url || uploadResponse?.url || ''
const fileId = uploadResponse?.data?.id || uploadResponse?.id
if (!fileId) {
throw new Error(`「${rawFile.name}」上传成功但未返回文件ID`)
}
return { id: fileId, url, name: rawFile.name }
}
const saveItemReceiptFiles = async (item, files, { append = true } = {}) => {
const existing = append ? getItemReceiptFiles(item) : []
const merged = [...existing]
const seen = new Set(merged.map(file => Number(file.file_id)).filter(Boolean))
files.forEach(file => {
const id = Number(file.id)
if (!id || seen.has(id)) return
seen.add(id)
merged.push({ file_id: id, url: file.url, name: file.name })
})
if (!merged.length) return
const ids = merged.map(file => file.file_id).filter(Boolean)
const urls = merged.map(file => file.url).filter(Boolean)
await updateReimbursementItem(props.detail.id, item.id, {
expense_type: item.expense_type,
expense_date: item.expense_date,
amount: item.amount,
quantity: item.quantity || 1,
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: 1,
receipt_file_ids: ids.join(','),
receipt_urls: urls.join(',')
})
}
const uploadFilesToItem = async (item, rawFiles) => {
const uploaded = []
for (const file of rawFiles) {
uploaded.push(await uploadFileToSystem(file))
}
await saveItemReceiptFiles(item, uploaded, { append: true })
return uploaded.length
}
const uploadInvoicesFromRow = async (startRow, rawFiles) => {
const files = rawFiles.filter(Boolean)
if (!files.length) return
const validFiles = files.filter(file => validateInvoiceFile(file))
if (!validFiles.length) return
batchUploading.value = true
try {
const count = await uploadFilesToItem(startRow, validFiles)
ElMessage.success(`已向「${startRow.expense_type || '当前费用项'}」追加 ${count} 张发票`)
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '发票上传失败')
} finally {
uploadingItemId.value = 0
batchUploading.value = false
}
}
const removeReceipt = async (receipt) => {
const openInvoiceFilePicker = row => {
pendingUploadRow.value = row
const input = invoiceFileInputRef.value
if (!input) return
input.value = ''
input.click()
}
const handleInvoiceFileInputChange = event => {
const row = pendingUploadRow.value
const files = Array.from(event.target.files || [])
pendingUploadRow.value = null
event.target.value = ''
if (!files.length || !row) return
uploadInvoicesFromRow(row, files)
}
const removeReceipt = async receipt => {
try {
await ElMessageBox.confirm('确认删除该票据吗?', '删除确认', { type: 'warning' })
// 通过原始 ID 查找对应的明细行
const row = props.detail.items.find(item => item.id === receipt.originalId)
if (!row) return
// 获取并过滤掉要删除的 URL
const existingUrls = row.receipt_url ? row.receipt_url.split(',').filter(Boolean) : []
const updatedUrls = existingUrls.filter(url => resolveReceiptUrl(url) !== receipt.url)
await updateReimbursementItem(props.detail.id, row.id, {
...row,
receipt_flag: updatedUrls.length > 0 ? 1 : 0,
receipt_url: updatedUrls.join(',')
const remaining = getItemReceiptFiles(row).filter(file => {
if (receipt.fileId) return Number(file.file_id) !== Number(receipt.fileId)
return resolveReceiptUrl(file.url) !== receipt.url
})
if (!remaining.length) {
await updateReimbursementItem(props.detail.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: 0,
receipt_file_ids: '',
receipt_urls: ''
})
} else {
const ids = remaining.map(file => file.file_id).filter(Boolean)
const urls = remaining.map(file => file.url).filter(Boolean)
await updateReimbursementItem(props.detail.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: 1,
receipt_file_ids: ids.join(','),
receipt_urls: urls.join(',')
})
}
ElMessage.success('票据已移除')
emit('changed', props.detail.id)
handleReceiptFilterChange()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('移除票据失败')
}
}
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[value] || 'info'
const actionLabel = value => ({ 1: '提交申请', 2: '审批通过', 3: '审批驳回', 4: '转审', 5: '撤回' })[value] || '审批操作'
const money = value => Number(value || 0).toFixed(2)
const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
@@ -323,6 +566,12 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
margin-top: 22px;
}
.section-actions {
display: flex;
align-items: center;
gap: 8px;
}
.detail-title {
margin: 0 0 12px;
color: #303133;
@@ -340,7 +589,7 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
}
.receipt-list {
flex: 0 0 200px;
flex: 0 0 220px;
display: flex;
flex-direction: column;
gap: 10px;
@@ -350,6 +599,10 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
max-height: 620px;
}
.receipt-filter {
width: 100%;
}
.receipt-thumb {
display: flex;
align-items: center;
@@ -371,10 +624,13 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
color: var(--el-color-primary);
}
span {
span,
.receipt-thumb-name {
flex: 1;
overflow: visible;
word-break: break-all;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -401,6 +657,11 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
overflow: visible;
}
.receipt-viewer-empty {
background: var(--el-fill-color-lighter);
border-radius: 4px;
}
.receipt-image {
display: block;
width: 100%;
@@ -423,14 +684,39 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
gap: 4px;
white-space: nowrap;
:deep(.el-upload) {
display: inline-flex;
}
.el-button {
margin: 0;
padding: 4px 6px;
font-size: 15px;
}
}
.invoice-file-input {
display: none;
}
.detail-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.toolbar-right {
display: flex;
gap: 10px;
}
.manual-tip {
margin: 0 0 16px;
color: #909399;
font-size: 13px;
}
.manual-status-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
</style>
@@ -24,7 +24,7 @@
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">创建</el-button>
<el-button type="primary" :loading="saving" @click="save">{{ submitLabel }}</el-button>
</template>
</el-dialog>
</template>
@@ -36,6 +36,7 @@ const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '新建报销单' },
formData: { type: Object, required: true },
isEdit: { type: Boolean, default: false },
expenseTypeOptions: { type: Array, default: () => [] },
saving: { type: Boolean, default: false }
})
@@ -55,6 +56,7 @@ const cloneForm = data => ({
const form = reactive(cloneForm(props.formData))
const formRef = ref()
const submitLabel = computed(() => (props.isEdit ? '保存' : '创建'))
const formRules = {
apply_date: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
}
@@ -0,0 +1,196 @@
<template>
<el-dialog
v-model="visible"
title="报销类别管理"
width="720px"
append-to-body
destroy-on-close
@open="loadList"
>
<div class="toolbar">
<p class="tip">通用费项由平台统一维护,租户仅可管理自行添加的专有费项。</p>
<el-button type="primary" :icon="Plus" @click="openForm()">新增专有费项</el-button>
</div>
<el-table v-loading="loading" :data="list" stripe max-height="420">
<el-table-column prop="name" label="费项名称" min-width="140" />
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.is_global ? 'warning' : ''" size="small">
{{ row.is_global ? '通用' : '租户专有' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<template v-if="row.editable">
<el-button link type="primary" @click="openForm(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
<span v-else class="readonly-tip">平台维护</span>
</template>
</el-table-column>
<template #empty><el-empty description="暂无费项" /></template>
</el-table>
<el-dialog
v-model="formVisible"
:title="editingId ? '编辑专有费项' : '新增专有费项'"
width="420px"
append-to-body
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" maxlength="50" show-word-limit placeholder="请输入费项名称" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveForm">保存</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
createReimbursementExpenseType,
deleteReimbursementExpenseType,
getReimbursementExpenseTypesManage,
updateReimbursementExpenseType
} from '@/api/reimburse'
const props = defineProps({
modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'changed'])
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const list = ref([])
const formVisible = ref(false)
const editingId = ref(0)
const formRef = ref(null)
const emptyForm = () => ({ name: '', sort_order: 0, is_active: 1 })
const form = reactive(emptyForm())
const rules = {
name: [
{ required: true, message: '请输入费项名称', trigger: 'blur' },
{ min: 1, max: 50, message: '名称长度 1-50 个字符', trigger: 'blur' }
]
}
const responseData = res => res?.data?.data ?? res?.data ?? res ?? []
watch(
() => props.modelValue,
val => { visible.value = val }
)
watch(visible, val => {
if (!val) emit('update:modelValue', false)
})
const loadList = async () => {
loading.value = true
try {
const data = responseData(await getReimbursementExpenseTypesManage())
list.value = Array.isArray(data) ? data : data.list || []
} catch (error) {
ElMessage.error(error?.message || '加载报销类别失败')
} finally {
loading.value = false
}
}
const openForm = row => {
editingId.value = row?.id || 0
Object.assign(form, row
? { name: row.name, sort_order: row.sort_order ?? 0, is_active: row.is_active ?? 1 }
: emptyForm())
formVisible.value = true
}
const saveForm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
const payload = { name: form.name.trim(), sort_order: form.sort_order, is_active: form.is_active }
if (editingId.value) {
await updateReimbursementExpenseType(editingId.value, payload)
ElMessage.success('更新成功')
} else {
await createReimbursementExpenseType(payload)
ElMessage.success('新增成功')
}
formVisible.value = false
await loadList()
emit('changed')
} catch (error) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
const handleDelete = async row => {
try {
await ElMessageBox.confirm(`确认删除费项「${row.name}」吗?`, '删除确认', { type: 'warning' })
await deleteReimbursementExpenseType(row.id)
ElMessage.success('删除成功')
await loadList()
emit('changed')
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error?.message || '删除失败')
}
}
}
</script>
<style lang="less" scoped>
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.tip {
margin: 0;
color: #909399;
font-size: 13px;
flex: 1;
}
.readonly-tip {
color: #c0c4cc;
font-size: 13px;
}
</style>
@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="visible"
:title="editingItemId ? '编辑费用明细' : '批量添加费用明细'"
:title="editingItemId ? '编辑费用明细' : '添加费用明细'"
width="860px"
append-to-body
destroy-on-close
@@ -60,7 +60,7 @@
<el-form-item label="金额" prop="amount">
<el-input-number
v-model="item.amount"
:min="0.01"
:min="0.00"
:precision="2"
:step="10"
controls-position="right"
@@ -123,7 +123,7 @@ const createRow = (source = {}) => ({
amount: Number(source.amount || 0),
description: source.description || '',
unit_price: Number(source.unit_price || 0),
receipt_url: source.receipt_url || ''
receipt_urls: source.receipt_urls || ''
})
const rules = {
@@ -169,7 +169,8 @@ const submit = async () => {
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: props.editingItem?.receipt_flag || 0,
receipt_url: item.receipt_url || ''
receipt_file_ids: props.editingItem?.receipt_file_ids || '',
receipt_urls: props.editingItem?.receipt_urls || ''
})
} else {
for (const item of items.value) {
@@ -181,7 +182,7 @@ const submit = async () => {
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: 0,
receipt_url: ''
receipt_urls: ''
})
}
}
+249 -143
View File
@@ -5,21 +5,45 @@
<h2>报销管理</h2>
<p>管理个人报销申请及审批进度</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建报销</el-button>
<div class="header-actions">
<el-button :icon="Setting" @click="expenseTypeVisible = true"
>报销类别</el-button
>
<el-button type="primary" :icon="Plus" @click="openCreate"
>新建报销</el-button
>
</div>
</div>
<div class="filter-card">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" clearable placeholder="请输入备注说明" @keyup.enter="loadList" />
<el-input
v-model="filters.keyword"
clearable
placeholder="请输入备注说明"
@keyup.enter="loadList"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-select
v-model="filters.status"
clearable
placeholder="全部状态"
style="width: 150px"
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadList">查询</el-button>
<el-button type="primary" :icon="Search" @click="loadList"
>查询</el-button
>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
@@ -27,29 +51,69 @@
<div class="table-card">
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="apply_date" label="申请日期" width="120">
<template #default="{ row }">{{ formatDate(row.apply_date) }}</template>
</el-table-column>
<el-table-column prop="description" label="备注说明" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column prop="total_amount" label="报销金额" width="130">
<template #default="{ row }">¥ {{ money(row.total_amount) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
<el-tag :type="statusType(row.status)">{{
statusLabel(row.status)
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="175">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
<el-table-column prop="apply_date" label="申请日期" width="120">
<template #default="{ row }">{{
formatDate(row.apply_date)
}}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="245">
<el-table-column prop="total_amount" label="报销金额" width="150">
<template #default="{ row }"
>¥ {{ money(row.total_amount) }}</template
>
</el-table-column>
<el-table-column
prop="description"
label="备注说明"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.description || "-" }}</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="175">
<template #default="{ row }">{{
formatDateTime(row.create_time)
}}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="300">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button v-if="row.status === 0" link type="primary" @click="openEdit(row.id)">编辑</el-button>
<el-button v-if="row.status === 0" link type="success" @click="handleSubmit(row)">提交</el-button>
<el-button v-if="row.status === 0" link type="danger" @click="handleDelete(row)">删除</el-button>
<el-button link type="primary" @click="openDetail(row.id)"
>详情</el-button
>
<el-button
link
type="primary"
:loading="exportingId === row.id"
@click="handleExport(row)"
>导出</el-button
>
<el-button
v-if="row.status === 0"
link
type="primary"
@click="openEdit(row.id)"
>编辑</el-button
>
<el-button
v-if="row.status === 0"
link
type="success"
@click="handleSubmit(row)"
>提交</el-button
>
<el-button
v-if="row.status === 0"
link
type="danger"
@click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
<template #empty><el-empty description="暂无报销记录" /></template>
@@ -83,15 +147,22 @@
:expense-type-options="expenseTypeOptions"
@changed="reloadDetail"
/>
<ExpenseTypeDialog
v-model="expenseTypeVisible"
@changed="loadExpenseTypes"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import ReimburseEdit from './components/edit.vue'
import ReimburseDetail from './components/detail.vue'
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh, Search, Setting } from "@element-plus/icons-vue";
import ReimburseEdit from "./components/edit.vue";
import ReimburseDetail from "./components/detail.vue";
import ExpenseTypeDialog from "./components/expenseTypeDialog.vue";
import { exportReimbursementPdf } from "./utils/exportReimbursementPdf";
import {
createReimbursement,
deleteReimbursement,
@@ -100,174 +171,203 @@ import {
getReimbursementList,
getReimbursementRecords,
submitReimbursement,
updateReimbursement
} from '@/api/reimburse'
updateReimbursement,
} from "@/api/reimburse";
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const filters = reactive({ keyword: '', status: undefined })
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const list = ref([])
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
const detailVisible = ref(false)
const editingId = ref(0)
const detail = ref(null)
const records = ref([])
const expenseTypeOptions = ref([])
{ value: 0, label: "草稿" },
{ value: 1, label: "审批中" },
{ value: 2, label: "已通过" },
{ value: 3, label: "已驳回" },
{ value: 4, label: "已撤回" },
{ value: 5, label: "已打款" },
];
const filters = reactive({ keyword: "", status: undefined });
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const list = ref([]);
const loading = ref(false);
const saving = ref(false);
const formVisible = ref(false);
const detailVisible = ref(false);
const expenseTypeVisible = ref(false);
const editingId = ref(0);
const detail = ref(null);
const records = ref([]);
const expenseTypeOptions = ref([]);
const exportingId = ref(0);
const emptyForm = () => ({
apply_date: new Date().toISOString().slice(0, 10),
description: ''
})
const form = reactive(emptyForm())
const formTitle = computed(() => editingId.value ? '编辑报销单' : '新建报销单')
description: "",
});
const form = reactive(emptyForm());
const formTitle = computed(() =>
editingId.value ? "编辑报销单" : "新建报销单",
);
const responseData = res => res?.data?.data ?? res?.data ?? res ?? {}
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const loadExpenseTypes = async () => {
try {
const data = responseData(await getReimbursementExpenseTypes())
expenseTypeOptions.value = Array.isArray(data) ? data : data.list || []
const data = responseData(await getReimbursementExpenseTypes());
const rows = Array.isArray(data) ? data : data.list || [];
expenseTypeOptions.value = rows.filter((item) => item.is_active !== 0);
} catch (error) {
ElMessage.error(error?.message || '加载费用类型失败')
ElMessage.error(error?.message || "加载费用类型失败");
}
}
};
const loadList = async () => {
loading.value = true
loading.value = true;
try {
const data = responseData(await getReimbursementList({ ...filters, ...pagination }))
list.value = data.list || []
pagination.total = Number(data.total || 0)
const data = responseData(
await getReimbursementList({ ...filters, ...pagination }),
);
list.value = data.list || [];
pagination.total = Number(data.total || 0);
} catch (error) {
ElMessage.error(error?.message || '加载报销列表失败')
ElMessage.error(error?.message || "加载报销列表失败");
} finally {
loading.value = false
loading.value = false;
}
}
};
const resetFilters = () => {
filters.keyword = ''
filters.status = undefined
pagination.page = 1
loadList()
}
filters.keyword = "";
filters.status = undefined;
pagination.page = 1;
loadList();
};
const resetForm = () => Object.assign(form, emptyForm())
const resetForm = () => Object.assign(form, emptyForm());
const openCreate = () => {
editingId.value = 0
resetForm()
formVisible.value = true
}
editingId.value = 0;
resetForm();
formVisible.value = true;
};
const openEdit = async id => {
const openEdit = async (id) => {
try {
const data = responseData(await getReimbursementDetail(id))
editingId.value = id
const data = responseData(await getReimbursementDetail(id));
editingId.value = id;
Object.assign(form, {
apply_date: data.apply_date || new Date().toISOString().slice(0, 10),
description: data.description || ''
})
formVisible.value = true
description: data.description || "",
});
formVisible.value = true;
} catch (error) {
ElMessage.error(error?.message || '加载报销详情失败')
ElMessage.error(error?.message || "加载报销详情失败");
}
}
};
const saveForm = async payload => {
saving.value = true
const saveForm = async (payload) => {
saving.value = true;
try {
if (editingId.value) await updateReimbursement(editingId.value, payload)
else await createReimbursement(payload)
ElMessage.success('保存成功')
formVisible.value = false
loadList()
if (editingId.value) await updateReimbursement(editingId.value, payload);
else await createReimbursement(payload);
ElMessage.success("保存成功");
formVisible.value = false;
loadList();
} catch (error) {
ElMessage.error(error?.message || '保存失败')
ElMessage.error(error?.message || "保存失败");
} finally {
saving.value = false
saving.value = false;
}
}
};
const handleSubmit = async row => {
const handleSubmit = async (row) => {
try {
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
await submitReimbursement(row.id)
ElMessage.success('提交成功')
loadList()
await ElMessageBox.confirm("提交后将进入审批流程,确认提交吗?", "提示", {
type: "warning",
});
await submitReimbursement(row.id);
ElMessage.success("提交成功");
loadList();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '提交失败')
if (error !== "cancel" && error !== "close")
ElMessage.error(error?.message || "提交失败");
}
}
};
const handleDelete = async row => {
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确认删除这条报销记录吗?', '删除确认', { type: 'warning' })
await deleteReimbursement(row.id)
ElMessage.success('删除成功')
loadList()
await ElMessageBox.confirm("确认删除这条报销记录吗?", "删除确认", {
type: "warning",
});
await deleteReimbursement(row.id);
ElMessage.success("删除成功");
loadList();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '删除失败')
if (error !== "cancel" && error !== "close")
ElMessage.error(error?.message || "删除失败");
}
}
};
const reloadDetail = async id => {
const reloadDetail = async (id) => {
try {
const [detailRes, recordsRes] = await Promise.all([
getReimbursementDetail(id),
getReimbursementRecords(id)
])
detail.value = responseData(detailRes)
records.value = responseData(recordsRes) || []
await loadList()
getReimbursementRecords(id),
]);
detail.value = responseData(detailRes);
records.value = responseData(recordsRes) || [];
await loadList();
} catch (error) {
ElMessage.error(error?.message || '刷新详情失败')
ElMessage.error(error?.message || "刷新详情失败");
}
}
};
const openDetail = async id => {
await reloadDetail(id)
detailVisible.value = true
}
const openDetail = async (id) => {
await reloadDetail(id);
detailVisible.value = true;
};
const money = value => Number(value || 0).toFixed(2)
const formatDate = value => {
if (!value) return '-'
const text = String(value)
const match = text.match(/^(\d{4}-\d{2}-\d{2})/)
if (match) return match[1]
return text
}
const formatDateTime = value => value
? new Date(value).toLocaleString('zh-CN', { hour12: false })
: '-'
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({
0: 'info',
1: 'warning',
2: 'success',
3: 'danger',
4: 'info',
5: 'success'
})[value] || 'info'
const handleExport = async (row) => {
exportingId.value = row.id;
try {
const data = responseData(await getReimbursementDetail(row.id));
await exportReimbursementPdf(data, {
statusLabel: statusLabel(data.status),
});
ElMessage.success("导出成功");
} catch (error) {
ElMessage.error(error?.message || "导出失败");
} finally {
exportingId.value = 0;
}
};
const money = (value) => Number(value || 0).toFixed(2);
const formatDate = (value) => {
if (!value) return "-";
const text = String(value);
const match = text.match(/^(\d{4}-\d{2}-\d{2})/);
if (match) return match[1];
return text;
};
const formatDateTime = (value) =>
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "-";
const statusLabel = (value) =>
statusOptions.find((item) => item.value === value)?.label || "未知";
const statusType = (value) =>
({
0: "info",
1: "warning",
2: "success",
3: "danger",
4: "info",
5: "success",
})[value] || "info";
onMounted(() => {
loadExpenseTypes()
loadList()
})
loadExpenseTypes();
loadList();
});
</script>
<style lang="less" scoped>
.reimburse-page {
min-height: calc(100vh - 84px);
// min-height: calc(100vh - 84px);
background: #f5f7fa;
padding: 20px;
}
@@ -279,6 +379,12 @@ onMounted(() => {
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.page-header h2 {
margin: 0 0 8px;
color: #303133;
@@ -296,7 +402,7 @@ onMounted(() => {
background: #fff;
border-radius: 6px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.filter-card {
@@ -0,0 +1,461 @@
import { jsPDF } from 'jspdf'
import html2canvas from 'html2canvas'
import * as pdfjsLib from 'pdfjs-dist'
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
const PAGE_WIDTH = 794
const PAGE_HEIGHT = 1123
const RECEIPTS_PER_PAGE = 2
const money = value => Number(value || 0).toFixed(2)
const resolveUrl = url => {
if (!url) return ''
if (/^https?:\/\//i.test(url) || url.startsWith('blob:') || url.startsWith('data:')) return url
return `${window.location.origin}/${String(url).replace(/^\/+/, '')}`
}
const isImageUrl = url => /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(url || '')
const isPdfUrl = url => /\.pdf(\?|#|$)/i.test(String(url || '').toLowerCase())
const getReceiptFiles = item => {
if (Array.isArray(item?.receipt_files) && item.receipt_files.length) {
return item.receipt_files
}
const ids = String(item?.receipt_file_ids || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
const urls = String(item?.receipt_urls || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
if (ids.length) {
return ids.map((id, index) => ({
file_id: Number(id),
url: urls[index] || urls[0] || '',
name: ''
}))
}
const firstUrl = urls[0] || ''
if (firstUrl) {
return [{ file_id: 0, url: firstUrl, name: '' }]
}
return []
}
const fetchAuthorizedBlob = async url => {
const token = localStorage.getItem('token')
const response = await fetch(resolveUrl(url), {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
if (!response.ok) {
throw new Error(`文件加载失败 (${response.status})`)
}
return response.blob()
}
const blobToDataUrl = blob =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
const loadImageDataUrl = async url => {
const blob = await fetchAuthorizedBlob(url)
return blobToDataUrl(blob)
}
const loadPdfPreviewDataUrl = async url => {
const blob = await fetchAuthorizedBlob(url)
const buffer = await blob.arrayBuffer()
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.4 })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvasContext: context, viewport }).promise
return canvas.toDataURL('image/jpeg', 0.92)
}
const resolveReceiptPreview = async file => {
const rawUrl = file.url || ''
const name = file.name || rawUrl.split('/').pop()?.split('?')[0] || '发票'
if (!rawUrl) {
return { name, dataUrl: '', note: '无可用文件地址' }
}
try {
if (isImageUrl(rawUrl)) {
return { name, dataUrl: await loadImageDataUrl(rawUrl), note: '' }
}
if (isPdfUrl(rawUrl)) {
return { name, dataUrl: await loadPdfPreviewDataUrl(rawUrl), note: '' }
}
return { name, dataUrl: '', note: '暂不支持预览该格式,请单独查看原文件' }
} catch (error) {
return { name, dataUrl: '', note: error?.message || '发票加载失败' }
}
}
const escapeHtml = value =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const EXPORT_STYLES = `
.reimburse-export-page {
width: ${PAGE_WIDTH}px;
height: ${PAGE_HEIGHT}px;
padding: 28px 32px 32px;
background: #fff;
color: #303133;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
box-sizing: border-box;
overflow: hidden;
}
.reimburse-export-page h1 {
margin: 0 0 20px;
font-size: 24px;
text-align: center;
}
.reimburse-export-page h2 {
margin: 0 0 14px;
font-size: 16px;
border-left: 4px solid #409eff;
padding-left: 10px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 24px;
margin-bottom: 18px;
font-size: 13px;
}
.summary-item span,
.summary-desc span,
.item-meta span {
color: #909399;
margin-right: 8px;
}
.summary-desc {
grid-column: 1 / -1;
font-size: 13px;
line-height: 1.6;
word-break: break-word;
}
.items-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.items-table th,
.items-table td {
border: 1px solid #dcdfe6;
padding: 8px 10px;
text-align: left;
vertical-align: top;
word-break: break-word;
}
.items-table th {
background: #f5f7fa;
color: #606266;
}
.item-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 20px;
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
font-size: 13px;
}
.item-meta-desc {
grid-column: 1 / -1;
line-height: 1.6;
word-break: break-word;
}
.reimburse-export-page.is-continuation {
height: auto;
min-height: 0;
padding-top: 28px;
}
.receipt-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.receipt-card {
padding: 10px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fff;
}
.receipt-title {
margin-bottom: 8px;
font-size: 12px;
color: #606266;
}
.receipt-image {
display: block;
width: auto;
max-width: min(100%, 680px);
height: auto;
max-height: var(--receipt-max-height, 480px);
object-fit: contain;
margin: 0 auto;
}
.receipt-note,
.empty-tip {
color: #909399;
font-size: 12px;
line-height: 1.6;
}
`
const createPageElement = html => {
const page = document.createElement('div')
page.className = 'reimburse-export-page'
page.innerHTML = html
return page
}
const buildItemsTableHtml = items => {
if (!items.length) {
return '<p class="empty-tip">暂无费用明细</p>'
}
const rows = items
.map(item => {
const count = getReceiptFiles(item).length
return `<tr>
<td>${escapeHtml(item.expense_type || '-')}</td>
<td>${escapeHtml(item.expense_date || '-')}</td>
<td>¥ ${money(item.amount)}</td>
<td>${count > 0 ? `有(${count})` : '无(0)'}</td>
<td>${escapeHtml(item.description || '-')}</td>
</tr>`
})
.join('')
return `<table class="items-table">
<thead>
<tr>
<th>费用类型</th>
<th>发生日期</th>
<th>金额</th>
<th>发票</th>
<th>描述</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`
}
const buildSummaryPageElement = (detail, items, statusLabel) =>
createPageElement(`
<h1>报销单</h1>
<div class="summary-grid">
<div class="summary-item"><span>报销单号</span>${escapeHtml(detail.id)}</div>
<div class="summary-item"><span>状态</span>${escapeHtml(statusLabel)}</div>
<div class="summary-item"><span>申请日期</span>${escapeHtml(detail.apply_date || '-')}</div>
<div class="summary-item"><span>合计金额</span>¥ ${money(detail.total_amount)}</div>
<div class="summary-desc"><span>备注说明</span>${escapeHtml(detail.description || '-')}</div>
</div>
<h2>费用明细</h2>
${buildItemsTableHtml(items)}
`)
const chunkList = (list, size) => {
const chunks = []
for (let i = 0; i < list.length; i += size) {
chunks.push(list.slice(i, i + size))
}
return chunks
}
const buildItemMetaHtml = (item, index, total) => `
<h2>费用项 ${index + 1} / ${total}</h2>
<div class="item-meta">
<div><span>费用类型</span>${escapeHtml(item.expense_type || '-')}</div>
<div><span>发生日期</span>${escapeHtml(item.expense_date || '-')}</div>
<div><span>金额</span>¥ ${money(item.amount)}</div>
<div><span>发票</span>${getReceiptFiles(item).length ? `有(${getReceiptFiles(item).length})` : '无(0)'}</div>
<div class="item-meta-desc"><span>描述</span>${escapeHtml(item.description || '-')}</div>
</div>
`
const buildReceiptCardsHtml = previews =>
previews
.map((preview, index) => `
<div class="receipt-card">
<div class="receipt-title">${escapeHtml(preview.name || `发票 ${index + 1}`)}</div>
${
preview.dataUrl
? `<img src="${preview.dataUrl}" alt="${escapeHtml(preview.name)}" class="receipt-image" />`
: `<div class="receipt-note">${escapeHtml(preview.note || '无法嵌入该发票')}</div>`
}
</div>
`)
.join('')
const getReceiptImageHeight = (countOnPage, hasFullMeta) => {
if (countOnPage <= 1) {
return hasFullMeta ? 760 : 860
}
return hasFullMeta ? 385 : 440
}
const buildItemReceiptPageElement = (
item,
index,
total,
previews,
{ showFullMeta }
) => {
const countOnPage = previews.length
const imageHeight = getReceiptImageHeight(countOnPage, showFullMeta)
const html = showFullMeta
? `
${buildItemMetaHtml(item, index, total)}
<h2>发票附件</h2>
<div class="receipt-list">${buildReceiptCardsHtml(previews)}</div>
`
: `<div class="receipt-list">${buildReceiptCardsHtml(previews)}</div>`
const page = createPageElement(html)
if (!showFullMeta) {
page.classList.add('is-continuation')
}
page.style.setProperty('--receipt-max-height', `${imageHeight}px`)
return page
}
const buildItemEmptyPageElement = (item, index, total) =>
createPageElement(`
${buildItemMetaHtml(item, index, total)}
<h2>发票附件</h2>
<p class="empty-tip">该费用项暂无发票</p>
`)
const buildItemPages = async (item, index, total) => {
const files = getReceiptFiles(item)
if (!files.length) {
return [buildItemEmptyPageElement(item, index, total)]
}
const previews = []
for (const file of files) {
previews.push(await resolveReceiptPreview(file))
}
const chunks = chunkList(previews, RECEIPTS_PER_PAGE)
return chunks.map((chunk, chunkIndex) =>
buildItemReceiptPageElement(item, index, total, chunk, {
showFullMeta: chunkIndex === 0
})
)
}
const waitForImages = async root => {
const images = Array.from(root.querySelectorAll('img'))
await Promise.all(
images.map(
img =>
new Promise(resolve => {
if (img.complete) {
resolve()
return
}
img.onload = () => resolve()
img.onerror = () => resolve()
})
)
)
}
const renderElementToCanvas = async (element, isContinuation = false) => {
await waitForImages(element)
const options = {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
windowWidth: PAGE_WIDTH
}
if (!isContinuation) {
options.width = PAGE_WIDTH
}
return html2canvas(element, options)
}
const addCanvasToPdf = (pdf, canvas, isFirstPage) => {
if (!isFirstPage) {
pdf.addPage()
}
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const margin = 8
const maxWidth = pageWidth - margin * 2
const maxHeight = pageHeight - margin * 2
let drawWidth = maxWidth
let drawHeight = (canvas.height * drawWidth) / canvas.width
if (drawHeight > maxHeight) {
drawHeight = maxHeight
drawWidth = (canvas.width * drawHeight) / canvas.height
}
const x = (pageWidth - drawWidth) / 2
const y = margin
pdf.addImage(canvas.toDataURL('image/jpeg', 0.95), 'JPEG', x, y, drawWidth, drawHeight)
}
export async function exportReimbursementPdf(detail, { statusLabel }) {
if (!detail?.id) {
throw new Error('报销单数据无效')
}
const items = detail?.items || []
const host = document.createElement('div')
host.style.cssText = 'position:fixed;left:-10000px;top:0;z-index:-1;'
const style = document.createElement('style')
style.textContent = EXPORT_STYLES
host.appendChild(style)
document.body.appendChild(host)
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
let pageIndex = 0
try {
const summaryPage = buildSummaryPageElement(detail, items, statusLabel)
host.appendChild(summaryPage)
const summaryCanvas = await renderElementToCanvas(summaryPage)
addCanvasToPdf(pdf, summaryCanvas, pageIndex === 0)
pageIndex += 1
host.removeChild(summaryPage)
for (let index = 0; index < items.length; index += 1) {
const itemPages = await buildItemPages(items[index], index, items.length)
for (const itemPage of itemPages) {
const isContinuation = itemPage.classList.contains('is-continuation')
host.appendChild(itemPage)
const itemCanvas = await renderElementToCanvas(itemPage, isContinuation)
addCanvasToPdf(pdf, itemCanvas, pageIndex === 0)
pageIndex += 1
host.removeChild(itemPage)
}
}
pdf.save(`报销单-${detail.id}-${detail.apply_date || 'export'}.pdf`)
} finally {
document.body.removeChild(host)
}
}