优化backend报销管理模块
This commit is contained in:
@@ -45,6 +45,14 @@ export function submitReimbursement(id) {
|
||||
})
|
||||
}
|
||||
|
||||
export function updateReimbursementStatus(id, data) {
|
||||
return request({
|
||||
url: `/backend/reimbursements/${id}/status`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getReimbursementRecords(id) {
|
||||
return request({
|
||||
url: `/backend/reimbursements/${id}/records`,
|
||||
@@ -88,3 +96,33 @@ export function getReimbursementExpenseTypes() {
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getReimbursementExpenseTypesManage() {
|
||||
return request({
|
||||
url: '/backend/reimbursements/types/manage',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createReimbursementExpenseType(data) {
|
||||
return request({
|
||||
url: '/backend/reimbursements/types',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateReimbursementExpenseType(id, data) {
|
||||
return request({
|
||||
url: `/backend/reimbursements/types/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteReimbursementExpenseType(id) {
|
||||
return request({
|
||||
url: `/backend/reimbursements/types/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user