增加报销模块功能
This commit is contained in:
Vendored
+2
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user