增加报销模块功能

This commit is contained in:
2026-07-20 17:50:47 +08:00
parent f00ba9f9cc
commit a78666177a
15 changed files with 2706 additions and 248 deletions
+2
View File
@@ -65,6 +65,8 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
+90
View File
@@ -0,0 +1,90 @@
import request from '@/utils/request'
export function getReimbursementList(params) {
return request({
url: '/backend/reimbursements',
method: 'get',
params
})
}
export function getReimbursementDetail(id) {
return request({
url: `/backend/reimbursements/${id}`,
method: 'get'
})
}
export function createReimbursement(data) {
return request({
url: '/backend/reimbursements',
method: 'post',
data
})
}
export function updateReimbursement(id, data) {
return request({
url: `/backend/reimbursements/${id}`,
method: 'post',
data
})
}
export function deleteReimbursement(id) {
return request({
url: `/backend/reimbursements/${id}`,
method: 'delete'
})
}
export function submitReimbursement(id) {
return request({
url: `/backend/reimbursements/${id}/submit`,
method: 'post'
})
}
export function getReimbursementRecords(id) {
return request({
url: `/backend/reimbursements/${id}/records`,
method: 'get'
})
}
export function addReimbursementItem(id, data) {
return request({
url: `/backend/reimbursements/${id}/items`,
method: 'post',
data
})
}
export function updateReimbursementItem(id, itemId, data) {
return request({
url: `/backend/reimbursements/${id}/items/${itemId}`,
method: 'post',
data
})
}
export function deleteReimbursementItem(id, itemId) {
return request({
url: `/backend/reimbursements/${id}/items/${itemId}`,
method: 'delete'
})
}
export function getApprovalFlows() {
return request({
url: '/backend/reimbursements/flows',
method: 'get'
})
}
export function getReimbursementExpenseTypes() {
return request({
url: '/backend/reimbursements/types',
method: 'get'
})
}
@@ -0,0 +1,329 @@
<template>
<div class="erp-dashboard">
<!-- 第一行统计卡片 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="4" v-for="(item, index) in firstRowStats" :key="'first-' + index">
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">{{ item.label }}</span>
<el-icon class="stat-icon"><Info-Filled /></el-icon>
</div>
<div class="stat-value">
<span class="currency">¥</span>
<span class="number">{{ formatMoney(item.value) }}</span>
</div>
</div>
</el-col>
</el-row>
<!-- 第二行统计卡片 -->
<el-row :gutter="16" class="stat-row" style="margin-top: 16px;">
<el-col :span="4" v-for="(item, index) in secondRowStats" :key="'second-' + index">
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">{{ item.label }}</span>
<el-icon class="stat-icon"><Info-Filled /></el-icon>
</div>
<div class="stat-value">
<span class="currency">¥</span>
<span class="number">{{ formatMoney(item.value) }}</span>
</div>
</div>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="16" class="chart-row" style="margin-top: 16px;">
<el-col :span="8">
<div class="chart-card">
<div class="chart-title">销售统计</div>
<div ref="salesChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="8">
<div class="chart-card">
<div class="chart-title">零售统计</div>
<div ref="retailChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="8">
<div class="chart-card">
<div class="chart-title">采购统计</div>
<div ref="purchaseChartRef" class="chart-container"></div>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue';
import * as echarts from 'echarts';
import { InfoFilled } from '@element-plus/icons-vue';
// 统计卡片数据 - 第一行
const firstRowStats = ref([
{ label: '今日销售', value: 0 },
{ label: '今日零售', value: 0 },
{ label: '今日采购', value: 0 },
{ label: '本月累计销售', value: 0 },
{ label: '本月累计零售', value: 0 },
{ label: '本月累计采购', value: 0 },
]);
// 统计卡片数据 - 第二行
const secondRowStats = ref([
{ label: '昨日销售', value: 0 },
{ label: '昨日零售', value: 0 },
{ label: '昨日采购', value: 0 },
{ label: '今年累计销售', value: 0 },
{ label: '今年累计零售', value: 0 },
{ label: '今年累计采购', value: 0 },
]);
// 图表实例
const salesChartRef = ref<HTMLElement | null>(null);
const retailChartRef = ref<HTMLElement | null>(null);
const purchaseChartRef = ref<HTMLElement | null>(null);
const salesChartInstance = shallowRef<echarts.ECharts | null>(null);
const retailChartInstance = shallowRef<echarts.ECharts | null>(null);
const purchaseChartInstance = shallowRef<echarts.ECharts | null>(null);
// 格式化金额
const formatMoney = (value: number): string => {
return value.toLocaleString();
};
// 生成最近6个月的月份标签
const getMonthLabels = (): string[] => {
const labels: string[] = [];
const now = new Date();
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
labels.push(`${year}-${month}`);
}
return labels;
};
// 初始化图表通用配置
const initChart = (refEl: HTMLElement, title: string, color: string) => {
const chart = echarts.init(refEl);
const months = getMonthLabels();
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: months,
axisLine: { lineStyle: { color: '#E0E6ED' } },
axisLabel: { color: '#606266', fontSize: 11 }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#F2F6FC' } },
axisLabel: { color: '#606266', fontSize: 11 }
},
series: [
{
name: title,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: color, width: 2 },
itemStyle: { color: color },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color + '40' },
{ offset: 1, color: color + '10' }
])
},
data: [0, 0, 0, 0, 0, 0]
}
]
});
return chart;
};
// 初始化所有图表
const initCharts = () => {
if (salesChartRef.value) {
salesChartInstance.value = initChart(salesChartRef.value, '销售', '#409EFF');
}
if (retailChartRef.value) {
retailChartInstance.value = initChart(retailChartRef.value, '零售', '#67C23A');
}
if (purchaseChartRef.value) {
purchaseChartInstance.value = initChart(purchaseChartRef.value, '采购', '#E6A23C');
}
};
// 加载数据(模拟接口调用)
const loadData = async () => {
// TODO: 调用接口获取真实数据
// const res = await getErpDashboard();
// 模拟数据更新
firstRowStats.value = [
{ label: '今日销售', value: 12580 },
{ label: '今日零售', value: 8560 },
{ label: '今日采购', value: 23400 },
{ label: '本月累计销售', value: 358600 },
{ label: '本月累计零售', value: 245800 },
{ label: '本月累计采购', value: 568900 },
];
secondRowStats.value = [
{ label: '昨日销售', value: 15230 },
{ label: '昨日零售', value: 9870 },
{ label: '昨日采购', value: 18900 },
{ label: '今年累计销售', value: 2856000 },
{ label: '今年累计零售', value: 1985000 },
{ label: '今年累计采购', value: 4568000 },
];
// 更新图表数据
updateCharts();
};
// 更新图表数据
const updateCharts = () => {
const salesData = [28000, 32000, 35000, 38000, 42000, 358600];
const retailData = [18000, 21000, 22000, 25000, 28000, 245800];
const purchaseData = [45000, 48000, 52000, 55000, 58000, 568900];
if (salesChartInstance.value) {
salesChartInstance.value.setOption({ series: [{ data: salesData }] });
}
if (retailChartInstance.value) {
retailChartInstance.value.setOption({ series: [{ data: retailData }] });
}
if (purchaseChartInstance.value) {
purchaseChartInstance.value.setOption({ series: [{ data: purchaseData }] });
}
};
// 自适应窗口大小
const handleResize = () => {
salesChartInstance.value?.resize();
retailChartInstance.value?.resize();
purchaseChartInstance.value?.resize();
};
onMounted(async () => {
initCharts();
await nextTick();
await loadData();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
salesChartInstance.value?.dispose();
retailChartInstance.value?.dispose();
purchaseChartInstance.value?.dispose();
});
</script>
<style lang="less" scoped>
.erp-dashboard {
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
}
.stat-row {
.el-col {
margin-bottom: 0;
}
}
.stat-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.stat-label {
font-size: 14px;
color: #606266;
}
.stat-icon {
font-size: 14px;
color: #c0c4cc;
cursor: help;
}
}
.stat-value {
display: flex;
align-items: baseline;
.currency {
font-size: 14px;
color: #303133;
font-weight: 500;
margin-right: 4px;
}
.number {
font-size: 24px;
font-weight: 600;
color: #303133;
font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif;
}
}
}
.chart-row {
.el-col {
margin-bottom: 0;
}
}
.chart-card {
background: #fff;
border-radius: 4px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
height: 320px;
display: flex;
flex-direction: column;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.chart-container {
flex: 1;
min-height: 0;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<script setup></script>
<style lang="less" scoped></style>
@@ -0,0 +1,398 @@
<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>
</el-descriptions>
<div class="section-header">
<h4 class="detail-title">费用明细</h4>
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
</div>
<el-table :data="detail?.items || []" border size="small">
<el-table-column prop="expense_type" label="费用类型" min-width="120" />
<el-table-column prop="expense_date" label="发生日期" width="120" />
<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>
<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"
link
type="primary"
title="查看票据"
aria-label="查看票据"
@click="openReceiptPreview(row)"
>
<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="上传发票"
>
<i
:class="uploadingItemId === row.id ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
></i>
</el-button>
</el-upload>
<el-button link type="primary" title="编辑" aria-label="编辑" @click="openEditItem(row)">
<i class="fa-solid fa-pen-to-square"></i>
</el-button>
<el-button link type="danger" title="删除" aria-label="删除" @click="removeItem(row)">
<i class="fa-regular fa-trash-can"></i>
</el-button>
</template>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="暂无费用明细" :image-size="60" /></template>
</el-table>
<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">
<button
v-for="receipt in receiptList"
:key="receipt.id"
type="button"
class="receipt-thumb"
:class="{ active: receipt.id === receiptPreviewItemId }"
@click="selectReceipt(receipt)"
>
<el-image
v-if="receipt.isImage"
:src="receipt.url"
fit="cover"
class="receipt-thumb-image"
lazy
/>
<i v-else class="fa-regular fa-file-pdf receipt-thumb-pdf"></i>
<span>{{ receipt.name }}</span>
</button>
</aside>
<main v-if="receiptPreviewUrl" class="receipt-viewer">
<el-image
v-if="isReceiptImage"
:src="receiptPreviewUrl"
:preview-src-list="[receiptPreviewUrl]"
fit="contain"
class="receipt-image"
preview-teleported
/>
<iframe v-else :src="receiptPreviewUrl" title="票据 PDF 预览" class="receipt-pdf"></iframe>
</main>
</div>
</el-dialog>
<h4 class="detail-title">审批记录</h4>
<el-timeline v-if="records.length">
<el-timeline-item
v-for="record in records"
:key="record.id"
:timestamp="formatDateTime(record.create_time)"
placement="top"
>
<strong>{{ actionLabel(record.action) }} · {{ record.approver_name || '-' }}</strong>
<div v-if="record.comment" class="record-comment">{{ record.comment }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无审批记录" :image-size="60" />
<ItemForm
v-model="itemVisible"
:reimbursement-id="detail?.id"
:editing-item-id="editingItemId"
:editing-item="editingItem"
:expense-type-options="expenseTypeOptions"
@saved="handleItemsSaved"
/>
</el-dialog>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadFile } from '@/api/file'
import { deleteReimbursementItem, updateReimbursementItem } from '@/api/reimburse'
import ItemForm from './itemForm.vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
detail: { type: Object, default: null },
records: { type: Array, default: () => [] },
expenseTypeOptions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'changed'])
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const isDraft = computed(() => props.detail?.status === 0)
const itemVisible = ref(false)
const uploadingItemId = ref(0)
const editingItemId = ref(0)
const editingItem = ref(null)
const receiptPreviewVisible = ref(false)
const receiptPreviewUrl = ref('')
const receiptPreviewType = ref('')
const receiptPreviewItemId = ref(0)
const receiptList = computed(() =>
(props.detail?.items || [])
.filter(item => item.receipt_url)
.map(item => {
const url = resolveReceiptUrl(item.receipt_url)
const isImage = /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(item.receipt_url || '')
return {
id: item.id,
url,
isImage,
name: item.expense_type || `票据 ${item.id}`
}
})
)
const isReceiptImage = computed(() => receiptPreviewType.value.startsWith('image/'))
const resolveReceiptUrl = url => {
if (!url) return ''
if (/^https?:\/\//i.test(url) || url.startsWith('blob:') || url.startsWith('data:')) return url
return `${window.location.origin}/${url.replace(/^\/+/, '')}`
}
const selectReceipt = receipt => {
receiptPreviewItemId.value = receipt.id
receiptPreviewUrl.value = receipt.url
receiptPreviewType.value = receipt.isImage ? 'image/*' : 'application/pdf'
}
const openReceiptPreview = row => {
const receipt = receiptList.value.find(item => item.id === row.id)
if (!receipt) return
selectReceipt(receipt)
receiptPreviewVisible.value = true
}
const openAddItem = () => {
editingItemId.value = 0
editingItem.value = null
itemVisible.value = true
}
const openEditItem = row => {
editingItemId.value = row.id
editingItem.value = row
itemVisible.value = true
}
const handleItemsSaved = reimbursementId => {
itemVisible.value = false
editingItemId.value = 0
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
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 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: uploadResponse?.data?.url || uploadResponse?.url || ''
})
ElMessage.success('发票上传成功')
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '发票上传失败')
} finally {
uploadingItemId.value = 0
}
}
const removeItem = async row => {
try {
await ElMessageBox.confirm(`确认删除“${row.expense_type}”这条费用明细吗?`, '删除确认', { type: 'warning' })
await deleteReimbursementItem(props.detail.id, row.id)
ElMessage.success('费用明细已删除')
emit('changed', props.detail.id)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '删除费用明细失败')
}
}
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 }) : '-'
</script>
<style lang="less" scoped>
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22px;
}
.detail-title {
margin: 0 0 12px;
color: #303133;
}
.record-comment {
margin-top: 6px;
color: #606266;
}
.receipt-preview {
display: flex;
gap: 16px;
min-height: 620px;
}
.receipt-list {
flex: 0 0 180px;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 12px;
border-right: 1px solid var(--el-border-color-light);
overflow-y: auto;
}
.receipt-thumb {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 72px;
padding: 8px;
overflow: visible;
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
cursor: pointer;
text-align: left;
&:hover,
&.active {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
span {
flex: 1;
overflow: visible;
word-break: break-all;
}
}
.receipt-thumb-image {
flex: 0 0 52px;
width: 52px;
height: 56px;
}
.receipt-thumb-pdf {
flex: 0 0 52px;
width: 52px;
color: var(--el-color-danger);
font-size: 34px;
text-align: center;
}
.receipt-viewer {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.receipt-image {
display: block;
width: 100%;
max-width: 100%;
max-height: 620px;
cursor: zoom-in;
}
.receipt-pdf {
width: 100%;
height: 620px;
border: 0;
}
.item-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
:deep(.el-upload) {
display: inline-flex;
}
.el-button {
margin: 0;
padding: 4px 6px;
font-size: 15px;
}
}
</style>
@@ -0,0 +1,73 @@
<template>
<el-dialog v-model="visible" :title="title" width="720px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px">
<el-form-item label="申请日期" prop="apply_date">
<el-date-picker
v-model="form.apply_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注说明">
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit />
</el-form-item>
<el-alert
title="保存报销单后,可在详情中逐条添加报销事项。"
type="info"
:closable="false"
show-icon
/>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">创建</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '新建报销单' },
formData: { type: Object, required: true },
expenseTypeOptions: { type: Array, default: () => [] },
saving: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'save'])
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const today = () => new Date().toISOString().slice(0, 10)
const cloneForm = data => ({
apply_date: data?.apply_date || today(),
description: data?.description || ''
})
const form = reactive(cloneForm(props.formData))
const formRef = ref()
const formRules = {
apply_date: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
}
watch(() => props.formData, value => {
Object.assign(form, cloneForm(value))
}, { deep: true })
const save = async () => {
if (!(await formRef.value.validate().catch(() => false))) return
emit('save', {
apply_date: form.apply_date,
description: form.description
})
}
</script>
@@ -0,0 +1,252 @@
<template>
<el-dialog
v-model="visible"
:title="editingItemId ? '编辑费用明细' : '批量添加费用明细'"
width="860px"
append-to-body
destroy-on-close
>
<div v-if="!editingItemId" class="batch-toolbar">
<span class="batch-tip">可同时填写多条费用明细保存后将一次性提交</span>
<el-button type="primary" plain size="small" @click="addRow">
<i class="fa-solid fa-plus"></i>
增加一行
</el-button>
</div>
<div class="item-list">
<el-card v-for="(item, index) in items" :key="item.key" shadow="never" class="item-card">
<template #header>
<div class="item-header">
<span>费用明细 {{ index + 1 }}</span>
<el-button
v-if="!editingItemId && items.length > 1"
link
type="danger"
title="删除此行"
aria-label="删除此行"
@click="removeRow(index)"
>
<i class="fa-regular fa-trash-can"></i>
</el-button>
</div>
</template>
<el-form :model="item" :rules="rules" label-width="80px" class="item-form">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="费用类型" prop="expense_type">
<el-select v-model="item.expense_type" placeholder="请选择" style="width: 100%">
<el-option
v-for="option in expenseTypeOptions"
:key="option.id"
:label="option.name"
:value="option.name"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发生日期" prop="expense_date">
<el-date-picker
v-model="item.expense_date"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="金额" prop="amount">
<el-input-number
v-model="item.amount"
:min="0.01"
:precision="2"
:step="10"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="费用描述" style="margin-top:20px">
<el-input
v-model="item.description"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
</el-card>
</div>
<el-empty v-if="!items.length" description="请添加费用明细" :image-size="70" />
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="!items.length" @click="submit">
{{ editingItemId ? '保存' : `保存 ${items.length} ` }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { addReimbursementItem, updateReimbursementItem } from '@/api/reimburse'
const props = defineProps({
modelValue: { type: Boolean, default: false },
reimbursementId: { type: [Number, String], default: 0 },
editingItemId: { type: [Number, String], default: 0 },
editingItem: { type: Object, default: null },
expenseTypeOptions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const saving = ref(false)
const items = ref([])
let rowKey = 0
const today = () => new Date().toISOString().slice(0, 10)
const createRow = (source = {}) => ({
key: ++rowKey,
expense_type: source.expense_type || '',
expense_date: source.expense_date || today(),
amount: Number(source.amount || 0),
description: source.description || '',
unit_price: Number(source.unit_price || 0),
receipt_url: source.receipt_url || ''
})
const rules = {
expense_type: [{ required: true, message: '请选择费用类型', trigger: 'change' }],
expense_date: [{ required: true, message: '请选择发生日期', trigger: 'change' }],
amount: [
{ required: true, message: '请输入费用金额', trigger: 'change' },
{ type: 'number', min: 0.01, message: '金额必须大于0', trigger: 'change' }
]
}
const resetItems = () => {
if (props.editingItemId && props.editingItem) {
items.value = [createRow(props.editingItem)]
} else {
items.value = [createRow()]
}
}
const addRow = () => items.value.push(createRow())
const removeRow = index => items.value.splice(index, 1)
const submit = async () => {
if (!items.value.length) return
const invalidIndex = items.value.findIndex(
item => !item.expense_type || !item.expense_date || Number(item.amount) <= 0
)
if (invalidIndex !== -1) {
ElMessage.warning(`请完善第 ${invalidIndex + 1} 条费用明细`)
return
}
saving.value = true
try {
if (props.editingItemId) {
const item = items.value[0]
await updateReimbursementItem(props.reimbursementId, props.editingItemId, {
expense_type: item.expense_type,
expense_date: item.expense_date,
amount: item.amount,
quantity: 1,
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: props.editingItem?.receipt_flag || 0,
receipt_url: item.receipt_url || ''
})
} else {
for (const item of items.value) {
await addReimbursementItem(props.reimbursementId, {
expense_type: item.expense_type,
expense_date: item.expense_date,
amount: item.amount,
quantity: 1,
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: 0,
receipt_url: ''
})
}
}
ElMessage.success(props.editingItemId ? '费用明细保存成功' : `成功添加 ${items.value.length} 条费用明细`)
visible.value = false
emit('saved', props.reimbursementId)
} catch (error) {
ElMessage.error(error?.message || '保存费用明细失败')
} finally {
saving.value = false
}
}
watch(
() => [props.modelValue, props.editingItemId, props.editingItem],
([isVisible]) => {
if (isVisible) resetItems()
},
{ immediate: true }
)
</script>
<style lang="less" scoped>
.batch-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.batch-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.item-list {
max-height: 540px;
overflow-y: auto;
padding-right: 4px;
}
.item-card {
margin-bottom: 12px;
border: 1px solid var(--el-border-color-light);
&:last-child {
margin-bottom: 0;
}
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.item-form {
:deep(.el-form-item) {
margin-bottom: 14px;
}
:deep(.el-form-item:last-child) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,330 @@
<template>
<div class="reimburse-page">
<div class="page-header">
<div>
<h2>报销管理</h2>
<p>管理个人报销申请及审批进度</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建报销</el-button>
</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-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>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadList">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<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">
<template #default="{ row }">
<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>
<el-table-column label="操作" fixed="right" width="245">
<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>
</template>
</el-table-column>
<template #empty><el-empty description="暂无报销记录" /></template>
</el-table>
<div class="pagination">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@update:current-page="pagination.page = $event"
@update:page-size="pagination.pageSize = $event"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</div>
<ReimburseEdit
v-model="formVisible"
:title="formTitle"
:form-data="form"
:saving="saving"
@save="saveForm"
/>
<ReimburseDetail
v-model="detailVisible"
:detail="detail"
:records="records"
:expense-type-options="expenseTypeOptions"
@changed="reloadDetail"
/>
</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 {
createReimbursement,
deleteReimbursement,
getReimbursementDetail,
getReimbursementExpenseTypes,
getReimbursementList,
getReimbursementRecords,
submitReimbursement,
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([])
const emptyForm = () => ({
apply_date: new Date().toISOString().slice(0, 10),
description: ''
})
const form = reactive(emptyForm())
const formTitle = computed(() => editingId.value ? '编辑报销单' : '新建报销单')
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 || []
} catch (error) {
ElMessage.error(error?.message || '加载费用类型失败')
}
}
const loadList = async () => {
loading.value = true
try {
const data = responseData(await getReimbursementList({ ...filters, ...pagination }))
list.value = data.list || []
pagination.total = Number(data.total || 0)
} catch (error) {
ElMessage.error(error?.message || '加载报销列表失败')
} finally {
loading.value = false
}
}
const resetFilters = () => {
filters.keyword = ''
filters.status = undefined
pagination.page = 1
loadList()
}
const resetForm = () => Object.assign(form, emptyForm())
const openCreate = () => {
editingId.value = 0
resetForm()
formVisible.value = true
}
const openEdit = async id => {
try {
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
} catch (error) {
ElMessage.error(error?.message || '加载报销详情失败')
}
}
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()
} catch (error) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
const handleSubmit = async row => {
try {
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
await submitReimbursement(row.id)
ElMessage.success('提交成功')
loadList()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '提交失败')
}
}
const handleDelete = async row => {
try {
await ElMessageBox.confirm('确认删除这条报销记录吗?', '删除确认', { type: 'warning' })
await deleteReimbursement(row.id)
ElMessage.success('删除成功')
loadList()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '删除失败')
}
}
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()
} catch (error) {
ElMessage.error(error?.message || '刷新详情失败')
}
}
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'
onMounted(() => {
loadExpenseTypes()
loadList()
})
</script>
<style lang="less" scoped>
.reimburse-page {
min-height: calc(100vh - 84px);
background: #f5f7fa;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-header h2 {
margin: 0 0 8px;
color: #303133;
font-size: 22px;
}
.page-header p {
margin: 0;
color: #909399;
font-size: 13px;
}
.filter-card,
.table-card {
background: #fff;
border-radius: 6px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
}
.filter-card {
margin-bottom: 16px;
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 768px) {
.reimburse-page {
padding: 12px;
}
.page-header {
align-items: flex-start;
gap: 12px;
}
.filter-card {
overflow-x: auto;
}
}
</style>