first commit

This commit is contained in:
2025-11-24 16:06:44 +08:00
commit e57d497751
165 changed files with 59349 additions and 0 deletions

View File

@@ -0,0 +1,944 @@
<template>
<ListPageLayout
title="发票管理"
subtitle="管理用户的发票申请"
>
<!-- 统计信息 -->
<!-- <template #actions>
<div class="flex gap-4">
<div class="stat-item">
<div class="stat-value">{{ stats.total || 0 }}</div>
<div class="stat-label">总申请数</div>
</div>
<div class="stat-item pending">
<div class="stat-value">{{ stats.pending || 0 }}</div>
<div class="stat-label">待处理</div>
</div>
<div class="stat-item completed">
<div class="stat-value">{{ stats.completed || 0 }}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-item rejected">
<div class="stat-value">{{ stats.rejected || 0 }}</div>
<div class="stat-label">已拒绝</div>
</div>
</div>
</template> -->
<template #filters>
<FilterSection>
<FilterItem label="状态筛选">
<el-select v-model="filters.status" placeholder="全部状态" clearable @change="handleFilterChange">
<el-option label="全部状态" value="" />
<el-option label="待处理" value="pending" />
<el-option label="已完成" value="completed" />
<el-option label="已拒绝" value="rejected" />
</el-select>
</FilterItem>
<FilterItem label="申请时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleDateRangeChange"
class="w-full"
/>
</FilterItem>
<template #stats>
共找到 {{ total }} 个申请
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadApplications">应用筛选</el-button>
</template>
</FilterSection>
</template>
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else-if="applications.length === 0" class="text-center py-12">
<el-empty description="暂无发票申请" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="applications"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="id" label="申请编号" >
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column prop="invoice_type" label="发票类型" width="160">
<template #default="{ row }">
<el-tag size="small" :type="row.invoice_type === 'special' ? 'warning' : 'info'" effect="light">
{{ getInvoiceTypeText(row.invoice_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="申请金额" width="120">
<template #default="{ row }">
<span class="font-semibold text-green-600">¥{{ formatPrice(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="申请公司" min-width="250">
<template #default="{ row }">
<div class="font-medium text-gray-900">{{ row.company_name }}</div>
<div class="text-sm text-gray-500">{{ row.taxpayer_id }}</div>
<div class="text-sm text-gray-500">{{ row.receiving_email }}</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag size="small" :type="getStatusTagType(row.status)" effect="light">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.created_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="220" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button
v-if="row.status === 'pending'"
size="small"
type="success"
@click="handleApprove(row)"
>
通过
</el-button>
<el-button
v-if="row.status === 'pending'"
size="small"
type="danger"
@click="handleReject(row)"
>
拒绝
</el-button>
<el-button
v-if="row.status === 'completed'"
size="small"
type="primary"
@click="handleDownload(row)"
>
下载
</el-button>
<el-button
size="small"
type="info"
@click="handleViewDetails(row)"
>
详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- 通过申请弹窗 -->
<el-dialog
v-model="approveDialogVisible"
title="通过发票申请"
width="500px"
class="approve-dialog"
>
<div v-if="selectedApplication" class="space-y-6">
<div class="approve-info">
<div class="info-item">
<span class="info-label">申请编号:</span>
<span class="info-value">{{ selectedApplication.id }}</span>
</div>
<div class="info-item">
<span class="info-label">申请公司:</span>
<span class="info-value">{{ selectedApplication.company_name }}</span>
</div>
<div class="info-item">
<span class="info-label">申请金额:</span>
<span class="info-value">¥{{ formatPrice(selectedApplication.amount) }}</span>
</div>
</div>
<el-form ref="approveFormRef" :model="approveForm" :rules="approveRules" label-width="100px">
<el-form-item label="上传发票" prop="file">
<el-upload
ref="uploadRef"
:auto-upload="false"
:on-change="handleFileChange"
:file-list="fileList"
accept=".pdf,.jpg,.jpeg,.png"
class="upload-demo"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">
支持 PDFJPGPNG 格式文件大小不超过 10MB
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="管理员备注">
<el-input
v-model="approveForm.admin_notes"
type="textarea"
:rows="3"
placeholder="可选:添加备注信息"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="approveDialogVisible = false">取消</el-button>
<el-button type="success" @click="confirmApprove" :loading="approveLoading">
确认通过
</el-button>
</div>
</template>
</el-dialog>
<!-- 拒绝申请弹窗 -->
<el-dialog
v-model="rejectDialogVisible"
title="拒绝发票申请"
width="500px"
class="reject-dialog"
>
<div v-if="selectedApplication" class="space-y-6">
<div class="reject-info">
<div class="info-item">
<span class="info-label">申请编号:</span>
<span class="info-value">{{ selectedApplication.id }}</span>
</div>
<div class="info-item">
<span class="info-label">申请公司:</span>
<span class="info-value">{{ selectedApplication.company_name }}</span>
</div>
<div class="info-item">
<span class="info-label">申请金额:</span>
<span class="info-value">¥{{ formatPrice(selectedApplication.amount) }}</span>
</div>
</div>
<el-form ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="100px">
<el-form-item label="拒绝原因" prop="reason">
<el-input
v-model="rejectForm.reason"
type="textarea"
:rows="4"
placeholder="请输入拒绝原因"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" @click="confirmReject" :loading="rejectLoading">
确认拒绝
</el-button>
</div>
</template>
</el-dialog>
<!-- 申请详情弹窗 -->
<el-dialog
v-model="detailsDialogVisible"
title="申请详情"
width="600px"
class="details-dialog"
>
<div v-if="selectedApplication" class="space-y-6">
<div class="details-grid">
<div class="detail-card">
<div class="detail-value">{{ selectedApplication.id }}</div>
<div class="detail-label">申请编号</div>
</div>
<div class="detail-card">
<div class="detail-value">¥{{ formatPrice(selectedApplication.amount) }}</div>
<div class="detail-label">申请金额</div>
</div>
</div>
<div class="details-info">
<div class="info-item">
<span class="info-label">发票类型:</span>
<span class="info-value">{{ getInvoiceTypeText(selectedApplication.invoice_type) }}</span>
</div>
<div class="info-item">
<span class="info-label">申请公司:</span>
<span class="info-value">{{ selectedApplication.company_name }}</span>
</div>
<div class="info-item">
<span class="info-label">纳税人识别号:</span>
<span class="info-value">{{ selectedApplication.taxpayer_id }}</span>
</div>
<div class="info-item">
<span class="info-label">接收邮箱:</span>
<span class="info-value">{{ selectedApplication.receiving_email }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">开户银行:</span>
<span class="info-value">{{ selectedApplication.bank_name }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">银行账号:</span>
<span class="info-value">{{ selectedApplication.bank_account }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">企业地址:</span>
<span class="info-value">{{ selectedApplication.company_address }}</span>
</div>
<div v-if="selectedApplication.invoice_type === 'special'" class="info-item">
<span class="info-label">企业电话:</span>
<span class="info-value">{{ selectedApplication.company_phone }}</span>
</div>
<div class="info-item">
<span class="info-label">申请时间:</span>
<span class="info-value">{{ formatDate(selectedApplication.created_at) }}</span>
</div>
<div v-if="selectedApplication.processed_at" class="info-item">
<span class="info-label">处理时间:</span>
<span class="info-value">{{ formatDate(selectedApplication.processed_at) }}</span>
</div>
<div v-if="selectedApplication.reject_reason" class="info-item">
<span class="info-label">拒绝原因:</span>
<span class="info-value reject-reason">{{ selectedApplication.reject_reason }}</span>
</div>
</div>
</div>
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { adminInvoiceApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 响应式数据
const loading = ref(false)
const applications = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const dateRange = ref([])
// 统计数据
const stats = ref({
total: 0,
pending: 0,
completed: 0,
rejected: 0
})
// 筛选条件
const filters = reactive({
status: ''
})
// 弹窗状态
const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const detailsDialogVisible = ref(false)
const selectedApplication = ref(null)
// 表单数据
const approveFormRef = ref()
const rejectFormRef = ref()
const uploadRef = ref()
const fileList = ref([])
const approveForm = reactive({
file: null,
admin_notes: ''
})
const rejectForm = reactive({
reason: ''
})
// 加载状态
const approveLoading = ref(false)
const rejectLoading = ref(false)
// 表单验证规则
const approveRules = {
file: [
{ required: true, message: '请选择发票文件', trigger: 'change' }
]
}
const rejectRules = {
reason: [
{ required: true, message: '请输入拒绝原因', trigger: 'blur' },
{ min: 5, message: '拒绝原因至少5个字符', trigger: 'blur' }
]
}
// 初始化
onMounted(() => {
loadApplications()
loadStats()
})
// 加载申请列表
const loadApplications = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
status: filters.status,
}
// 添加时间范围(转换为后端需要的格式)
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = `${dateRange.value[0]} 00:00:00`
params.end_time = `${dateRange.value[1]} 23:59:59`
}
const response = await adminInvoiceApi.getPendingApplications(params)
if (response && response.data) {
applications.value = response.data.applications || []
total.value = response.data.total || 0
}
} catch (error) {
console.error('加载申请列表失败:', error)
} finally {
loading.value = false
}
}
// 加载统计数据
const loadStats = async () => {
try {
// 这里可以调用专门的统计API或者从列表数据中计算
// 暂时使用模拟数据
stats.value = {
total: total.value,
pending: applications.value.filter(app => app.status === 'pending').length,
completed: applications.value.filter(app => app.status === 'completed').length,
rejected: applications.value.filter(app => app.status === 'rejected').length
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 获取发票类型文本
const getInvoiceTypeText = (type) => {
switch (type) {
case 'general':
return '普通发票'
case 'special':
return '专用发票'
default:
return '未知类型'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'pending':
return '待处理'
case 'completed':
return '已完成'
case 'rejected':
return '已拒绝'
default:
return '未知状态'
}
}
// 获取状态标签类型
const getStatusTagType = (status) => {
switch (status) {
case 'pending':
return 'warning'
case 'completed':
return 'success'
case 'rejected':
return 'danger'
default:
return 'info'
}
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadApplications()
}
// 处理日期范围变化
const handleDateRangeChange = () => {
currentPage.value = 1
loadApplications()
}
// 重置筛选
const resetFilters = () => {
filters.status = ''
dateRange.value = []
currentPage.value = 1
loadApplications()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadApplications()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadApplications()
}
// 处理通过申请
const handleApprove = (application) => {
selectedApplication.value = application
approveDialogVisible.value = true
fileList.value = []
approveForm.file = null
approveForm.admin_notes = ''
}
// 处理拒绝申请
const handleReject = (application) => {
selectedApplication.value = application
rejectDialogVisible.value = true
rejectForm.reason = ''
}
// 处理查看详情
const handleViewDetails = (application) => {
selectedApplication.value = application
detailsDialogVisible.value = true
}
// 处理下载
const handleDownload = async (application) => {
try {
const response = await adminInvoiceApi.downloadInvoiceFile(application.id)
if (response && response.data) {
// 创建blob URL
const blob = new Blob([response.data], { type: 'application/pdf' })
const url = window.URL.createObjectURL(blob)
// 创建下载链接
const link = document.createElement('a')
link.href = url
link.download = `invoice_${application.id}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 清理blob URL
window.URL.revokeObjectURL(url)
ElMessage.success('开始下载发票文件')
}
} catch (error) {
console.error('下载发票失败:', error)
ElMessage.error('下载发票失败,请稍后重试')
}
}
// 处理文件选择
const handleFileChange = (file) => {
fileList.value = [file]
approveForm.file = file.raw // 更新表单数据
}
// 确认通过
const confirmApprove = async () => {
if (!approveFormRef.value) return
try {
await approveFormRef.value.validate()
approveLoading.value = true
const formData = new FormData()
formData.append('file', approveForm.file)
formData.append('admin_notes', approveForm.admin_notes)
const response = await adminInvoiceApi.approveInvoiceApplication(
selectedApplication.value.id,
formData
)
if (response) {
ElMessage.success('申请已通过')
approveDialogVisible.value = false
loadApplications()
loadStats()
}
} catch (error) {
console.error('通过申请失败:', error)
} finally {
approveLoading.value = false
}
}
// 确认拒绝
const confirmReject = async () => {
if (!rejectFormRef.value) return
try {
await rejectFormRef.value.validate()
rejectLoading.value = true
const response = await adminInvoiceApi.rejectInvoiceApplication(
selectedApplication.value.id,
{ reason: rejectForm.reason }
)
if (response) {
ElMessage.success('申请已拒绝')
rejectDialogVisible.value = false
loadApplications()
loadStats()
}
} catch (error) {
console.error('拒绝申请失败:', error)
ElMessage.error('拒绝申请失败')
} finally {
rejectLoading.value = false
}
}
</script>
<style scoped>
/* 统计项样式 */
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 12px;
min-width: 120px;
transition: all 0.3s ease;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-item.pending {
border-color: rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
}
.stat-item.completed {
border-color: rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.05);
}
.stat-item.rejected {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1e293b;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #64748b;
font-weight: 500;
}
/* 弹窗样式 */
.approve-dialog :deep(.el-dialog),
.reject-dialog :deep(.el-dialog),
.details-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.approve-dialog :deep(.el-dialog__header),
.reject-dialog :deep(.el-dialog__header),
.details-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.approve-dialog :deep(.el-dialog__title),
.reject-dialog :deep(.el-dialog__title),
.details-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.approve-dialog :deep(.el-dialog__body),
.reject-dialog :deep(.el-dialog__body),
.details-dialog :deep(.el-dialog__body) {
padding: 24px;
}
.approve-dialog :deep(.el-dialog__footer),
.reject-dialog :deep(.el-dialog__footer),
.details-dialog :deep(.el-dialog__footer) {
background: rgba(248, 250, 252, 0.5);
border-top: 1px solid rgba(226, 232, 240, 0.4);
padding: 16px 24px;
}
/* 申请信息样式 */
.approve-info,
.reject-info {
background: rgba(248, 250, 252, 0.5);
border: 1px solid rgba(226, 232, 240, 0.4);
border-radius: 8px;
padding: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(226, 232, 240, 0.3);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.info-value {
font-size: 14px;
color: #1e293b;
font-weight: 600;
}
.reject-reason {
color: #dc2626;
font-weight: 500;
}
/* 详情网格样式 */
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.detail-card {
background: rgba(248, 250, 252, 0.8);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 12px;
padding: 20px;
text-align: center;
transition: all 0.2s ease;
}
.detail-card:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(59, 130, 246, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.detail-value {
font-size: 24px;
font-weight: 700;
color: #1e293b;
line-height: 1;
margin-bottom: 8px;
}
.detail-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
/* 详情信息样式 */
.details-info {
background: rgba(248, 250, 252, 0.5);
border: 1px solid rgba(226, 232, 240, 0.4);
border-radius: 8px;
padding: 16px;
}
/* 上传组件样式 */
.upload-demo {
width: 100%;
}
.el-upload__tip {
color: #64748b;
font-size: 12px;
margin-top: 8px;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stat-item {
padding: 12px 16px;
min-width: 100px;
}
.stat-value {
font-size: 20px;
}
.stat-label {
font-size: 12px;
}
.details-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.detail-card {
padding: 16px;
}
.detail-value {
font-size: 20px;
}
.detail-label {
font-size: 13px;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.info-label {
width: auto;
}
}
</style>