账单异步生成优化

This commit is contained in:
caojia
2026-09-20 16:42:11 +08:00
parent 8d1abf0eb1
commit 85b37bc07e
52 changed files with 3948 additions and 790 deletions
+30 -1
View File
@@ -1,5 +1,6 @@
import request from '@/utils/request'
// 应收账单接口统一放在这里;生成和导出均为异步任务,提交接口不等待后台处理完成。
export function listArBill(query) {
return request({ url: '/ar/bill/list', method: 'get', params: query })
}
@@ -8,12 +9,36 @@ export function getArBill(arBillId) {
return request({ url: '/ar/bill/' + arBillId, method: 'get', timeout: 30000 })
}
export function listArBillDetails(arBillId, query = {}) {
return request({ url: '/ar/bill/' + arBillId + '/details', method: 'get', params: query })
}
export function listArBillDetailFees(arBillId, detailId) {
return request({ url: '/ar/bill/' + arBillId + '/details/' + detailId + '/fees', method: 'get' })
}
export function getDefaultArBankAccount(currency = 'CNY') {
return request({ url: '/ar/bill/default-bank-account', method: 'get', params: { currency } })
}
export function generateArBill(data) {
return request({ url: '/ar/bill/generate', method: 'post', data, timeout: 60000 })
return request({ url: '/ar/bill/generate', method: 'post', data, timeout: 30000 })
}
export function getArBillGenerateTask(taskId) {
return request({ url: '/ar/bill/generate/task/' + taskId, method: 'get' })
}
export function listActiveArBillGenerateTasks() {
return request({ url: '/ar/bill/generate/tasks/active', method: 'get' })
}
export function cancelArBillGenerateTask(taskId) {
return request({ url: '/ar/bill/generate/task/' + taskId + '/cancel', method: 'post' })
}
export function retryArBillGenerateTask(taskId) {
return request({ url: '/ar/bill/generate/task/' + taskId + '/retry', method: 'post' })
}
export function previewArBill(query) {
@@ -36,6 +61,10 @@ export function listArBillExports(arBillId) {
return request({ url: '/ar/bill/' + arBillId + '/exports', method: 'get' })
}
export function getArBillExport(exportId) {
return request({ url: '/ar/bill/export/' + exportId, method: 'get' })
}
export function downloadArBillExport(exportId) {
return request({ url: '/ar/bill/export/' + exportId + '/download', method: 'get', responseType: 'blob' })
}
+6
View File
@@ -30,6 +30,12 @@ export const AR_BILL_STATUS_TYPES = Object.freeze({
[AR_BILL_STATUS.VOID]: 'danger'
})
/** 账单生成时的订单选择模式,value 与后端枚举和数据库 code 保持一致。 */
export const AR_BILL_SELECTION_MODE = Object.freeze({
EXPLICIT: 'EXPLICIT', // 手动选择当前预览结果
ALL_MATCHED: 'ALL_MATCHED' // 全选查询结果,再排除指定订单
})
/** 日志状态变化的兼容展示。日志是通用表,除账单状态外还可能保留历史收款状态。 */
export const AR_LOG_STATUS_OPTIONS = Object.freeze([
...AR_BILL_STATUS_OPTIONS,
@@ -17,17 +17,21 @@
<el-descriptions-item label="收款账号">{{ mask(detail.bankAccountNo) }}</el-descriptions-item>
</el-descriptions>
<div class="section-title">订单快照({{ detail.orderCount || 0 }})</div>
<el-table :data="detail.details || []" border size="small">
<!-- 账单明细独立分页;费用明细在展开行时再懒加载。 -->
<div class="section-title">账单明细({{ detail.orderCount || 0 }})</div>
<el-table v-loading="detailListLoading" :data="detailRows" row-key="detailId" border size="small" @expand-change="handleExpandChange">
<el-table-column type="expand">
<template #default="{ row }">
<el-table :data="row.fees || []" border size="small" class="fee-table">
<el-table-column prop="feeType" label="费用类型" width="140" />
<el-table-column prop="feeName" label="费用名称" min-width="160" />
<el-table-column prop="amount" label="金额" width="120" align="right"><template #default="{ row: fee }">{{ money(fee.amount) }}</template></el-table-column>
<el-table-column prop="currency" label="币种" width="80" />
<el-table-column prop="remark" label="说明" show-overflow-tooltip />
</el-table>
<div v-loading="row.feeLoading" class="fee-panel">
<el-empty v-if="row.feesLoaded && !row.fees?.length" description="暂无费用明细" :image-size="60" />
<el-table v-else :data="row.fees || []" border size="small" class="fee-table">
<el-table-column prop="feeType" label="费用类型" width="140" />
<el-table-column prop="feeName" label="费用名称" min-width="160" />
<el-table-column prop="amount" label="金额" width="120" align="right"><template #default="{ row: fee }">{{ money(fee.amount) }}</template></el-table-column>
<el-table-column prop="currency" label="币种" width="80" />
<el-table-column prop="remark" label="说明" show-overflow-tooltip />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="detailNo" label="#" width="55" align="center" />
@@ -38,12 +42,24 @@
<el-table-column label="重量/尺寸" min-width="170"><template #default="{ row }">{{ row.chargeWeight || '-' }} kg / {{ dimensions(row) }}</template></el-table-column>
<el-table-column prop="totalAmount" label="订单金额" width="120" align="right"><template #default="{ row }">{{ money(row.totalAmount) }}</template></el-table-column>
</el-table>
<el-pagination
v-if="detailTotal > 0"
class="detail-pagination"
v-model:current-page="detailPageNum"
v-model:page-size="detailPageSize"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="detailTotal"
@current-change="loadDetails"
@size-change="handleDetailSizeChange"
/>
<div class="section-title export-title"><span>导出历史</span><el-button size="small" type="primary" plain icon="Download" @click="$emit('export')">生成新文件</el-button></div>
<div class="section-title export-title"><span>导出历史</span><el-button size="small" type="primary" plain icon="Download" :loading="exporting" :disabled="exporting" @click="$emit('export')">{{ exporting ? '生成中' : '生成新文件' }}</el-button></div>
<el-table :data="detail.exports || []" border size="small">
<el-table-column prop="fileName" label="文件名" min-width="250" show-overflow-tooltip />
<el-table-column prop="templateVersion" label="模板版本" width="100" />
<el-table-column prop="exportStatus" label="状态" width="100"><template #default="{ row }"><el-tag :type="exportStatusType(row.exportStatus)">{{ exportStatusText(row.exportStatus) }}</el-tag></template></el-table-column>
<el-table-column prop="remark" label="失败原因" min-width="180" show-overflow-tooltip />
<el-table-column prop="exportBy" label="导出人" width="100" />
<el-table-column prop="exportTime" label="导出时间" width="170" />
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.exportStatus === AR_EXPORT_STATUS.SUCCESS" link type="primary" @click="$emit('download', row)">下载</el-button></template></el-table-column>
@@ -54,10 +70,64 @@
</template>
<script setup>
import { listArBillDetails, listArBillDetailFees } from '@/api/ar/bill'
import { AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES, AR_EXPORT_STATUS, AR_EXPORT_STATUS_OPTIONS, AR_EXPORT_STATUS_TYPES, arEnumLabel } from '@/constants/ar/enums'
defineProps({ modelValue: Boolean, detail: { type: Object, default: null }, loading: Boolean })
const props = defineProps({ modelValue: Boolean, detail: { type: Object, default: null }, loading: Boolean })
defineEmits(['update:modelValue', 'download', 'export'])
const detailRows = ref([])
const detailTotal = ref(0)
const detailPageNum = ref(1)
// 详情默认每页 10 条,避免几十万条账单明细一次性进入浏览器内存。
const detailPageSize = ref(10)
const detailListLoading = ref(false)
let detailRequestSerial = 0
const exporting = computed(() => props.detail?.exports?.some(item => item.exportStatus === AR_EXPORT_STATUS.PROCESSING))
// 切换账单时重置分页和请求序号,旧账单的慢响应不能覆盖新账单。
watch(() => props.detail?.arBillId, billId => {
detailRequestSerial += 1
detailPageNum.value = 1
detailTotal.value = 0
detailRows.value = []
if (billId) loadDetails()
}, { immediate: true })
// 账单明细接口只返回当前页;分页变化时复用同一账单 ID 重新查询。
function loadDetails() {
const billId = props.detail?.arBillId
if (!billId) return
const serial = ++detailRequestSerial
detailListLoading.value = true
listArBillDetails(billId, { pageNum: detailPageNum.value, pageSize: detailPageSize.value }).then(res => {
if (serial !== detailRequestSerial) return
detailRows.value = (res.rows || []).map(row => ({ ...row, fees: [], feesLoaded: false, feeLoading: false }))
detailTotal.value = Number(res.total) || 0
}).finally(() => {
if (serial === detailRequestSerial) detailListLoading.value = false
})
}
function handleDetailSizeChange(size) {
detailPageSize.value = size
detailPageNum.value = 1
loadDetails()
}
// 费用明细按行懒加载,并用标记避免同一行重复请求。
function handleExpandChange(row, expandedRows) {
if (!expandedRows.includes(row) || row.feesLoaded || row.feeLoading) return
const billId = props.detail?.arBillId
if (!billId || !row.detailId) return
row.feeLoading = true
listArBillDetailFees(billId, row.detailId).then(res => {
row.fees = res.data || []
row.feesLoaded = true
}).finally(() => {
row.feeLoading = false
})
}
function date(value) { return value ? String(value).slice(0, 10) : '-' }
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
function statusText(value) { return arEnumLabel(AR_BILL_STATUS_OPTIONS, value) }
@@ -75,5 +145,7 @@ function mask(value) { if (!value) return '-'; return value.length <= 4 ? '****'
.section-title::before { content: ''; width: 3px; height: 15px; border-radius: 2px; background: var(--el-color-primary); }
.export-title { justify-content: space-between; }
.export-title::before { margin-right: auto; }
.detail-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.fee-panel { min-height: 54px; padding: 4px 12px 8px 42px; background: #fafafa; }
.fee-table { background: #fafafa; }
</style>
@@ -6,6 +6,7 @@
<el-col :span="8"><el-form-item label="客户" prop="customerId"><customer-select v-model="generateForm.customerId" placeholder="请选择客户" @change="handleCustomerChange" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="币种" prop="currency"><el-select v-model="generateForm.currency" placeholder="请选择币种" style="width: 100%;" @change="handleCurrencyChange"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="账单截止日" prop="cutoffDate"><el-date-picker v-model="generateForm.cutoffDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择账单截止日" style="width: 100%;" @change="resetPreview" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="订单/运单号"><el-input v-model="generateForm.orderOrTrackingNo" placeholder="请输入订单号或运单号" clearable /></el-form-item></el-col>
</el-row>
</el-form>
<div class="payee-card" v-loading="bankAccountLoading">
@@ -18,14 +19,47 @@
<span v-else class="payee-empty">当前币种尚未配置公司默认收款账户</span>
</div>
<div class="preview-toolbar">
<span v-if="previewSearched" class="preview-summary">可选 {{ previewRows.length }} 条订单,已选 <b>{{ selectedRows.length }}</b> 条,应收合计 <b>{{ money(selectedAmount) }}</b></span>
<span v-if="previewSearched" class="preview-summary">可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条,应收合计 <b>{{ money(displaySelectedAmount) }}</b></span>
<span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span>
<el-button type="primary" plain :loading="previewLoading" :disabled="!bankAccount" @click="queryGeneratePreview">查询</el-button>
</div>
<div v-if="previewSearched" class="preview-selection-toolbar">
<el-tooltip content="全选全部查询结果" placement="top">
<el-checkbox
:model-value="allSelected"
:disabled="!previewTotal"
aria-label="全选全部查询结果"
@change="handleSelectAllChange"
>
<el-icon><Select /></el-icon>
</el-checkbox>
</el-tooltip>
<el-tooltip content="切换手动选择" placement="top">
<el-checkbox
:model-value="selectionMode === AR_BILL_SELECTION_MODE.EXPLICIT"
:disabled="!previewTotal || selectionMode === AR_BILL_SELECTION_MODE.EXPLICIT"
aria-label="切换手动选择"
@change="handleManualModeChange"
>
<el-icon><Refresh /></el-icon>
</el-checkbox>
</el-tooltip>
<el-badge v-if="allSelected" :value="excludedRows.length" :hidden="!excludedRows.length" class="exclude-order-badge">
<el-tooltip content="选择排除订单" placement="top">
<el-button circle text :icon="Search" aria-label="选择排除订单" @click="openExcludeDialog" />
</el-tooltip>
</el-badge>
</div>
<div v-if="allSelected && excludedRows.length" class="excluded-order-preview">
<span class="excluded-order-title">排除订单:</span>
<el-tag v-for="row in excludedRows" :key="row.billId" closable size="small" @close="removeExcludedRow(row.billId)">
{{ row.orderNo || row.trackingNo || row.billId }}
</el-tag>
</div>
<el-table ref="previewTableRef" v-loading="previewLoading" :data="previewRows" row-key="billId" border stripe max-height="400" empty-text="暂无符合条件的计费订单" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" fixed="left" />
<el-table-column type="selection" width="50" fixed="left" :selectable="isMainRowSelectable" />
<el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="跟踪号" min-width="140" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="运单号" min-width="140" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="130" show-overflow-tooltip />
<el-table-column prop="bizDate" label="业务日期" width="105" :formatter="formatDateColumn" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
@@ -35,20 +69,59 @@
<el-table-column prop="incomeTotal" label="应收金额" width="110" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="currency" label="币种" width="75" />
</el-table>
<el-pagination
v-if="previewTotal > 0"
class="preview-pagination"
:current-page="previewPageNum"
:page-size="previewPageSize"
layout="total, prev, pager, next, jumper"
:total="previewTotal"
@current-change="handlePreviewPageChange"
/>
<template #footer>
<span class="generate-footer-summary">已选 {{ selectedRows.length }} 条订单 · {{ money(selectedAmount) }}</span>
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !selectedRows.length" @click="submitGenerate">生成账单</el-button>
<span class="generate-footer-summary">已选 {{ displaySelectedCount }} 条订单 · {{ money(displaySelectedAmount) }}</span>
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !displaySelectedCount" @click="submitGenerate">生成账单</el-button>
<el-button @click="dialogOpen = false">取消</el-button>
</template>
</el-dialog>
<el-dialog v-model="excludeDialogOpen" title="选择排除订单" width="1100px" append-to-body destroy-on-close @close="handleExcludeDialogClose">
<div class="exclude-toolbar">
<el-input v-model="excludeKeyword" clearable placeholder="请输入订单号或运单号" class="exclude-search-input" />
<el-button type="primary" :loading="excludeLoading" @click="queryExcludeRows">查询</el-button>
</div>
<el-table ref="excludeTableRef" v-loading="excludeLoading" :data="excludeRowsPage" row-key="billId" border stripe max-height="420" empty-text="暂无符合条件的订单" @selection-change="handleExcludeSelectionChange">
<el-table-column type="selection" width="50" fixed="left" />
<el-table-column prop="orderNo" label="订单号" min-width="220" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="运单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="bizDate" label="业务日期" width="120" :formatter="formatDateColumn" />
<el-table-column prop="incomeTotal" label="应收金额" width="130" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="currency" label="币种" width="80" />
</el-table>
<el-pagination
v-if="excludeTotal > 0"
class="exclude-pagination"
:current-page="excludePageNum"
:page-size="excludePageSize"
layout="total, prev, pager, next, jumper"
:total="excludeTotal"
@current-change="handleExcludePageChange"
/>
<template #footer>
<span class="generate-footer-summary">已选择 {{ excludeDraftRows.length }} 条排除订单</span>
<el-button @click="cancelExcludeSelection">取消</el-button>
<el-button type="primary" @click="confirmExcludeSelection">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="billNameOpen" title="填写账单名称" width="520px" append-to-body destroy-on-close>
<el-form ref="billNameFormRef" :model="billNameForm" :rules="billNameRules" label-width="90px">
<el-form-item label="账单名称" prop="billName">
<el-input v-model="billNameForm.billName" maxlength="100" show-word-limit placeholder="请输入账单名称" clearable @keyup.enter="confirmGenerate" />
</el-form-item>
</el-form>
<div class="bill-name-summary">已选择 {{ selectedRows.length }} 条订单,应收合计 {{ money(selectedAmount) }}</div>
<div class="bill-name-summary">已选择 {{ displaySelectedCount }} 条订单,应收合计 {{ money(displaySelectedAmount) }}</div>
<template #footer>
<el-button @click="billNameOpen = false">取消</el-button>
<el-button type="primary" :loading="generateLoading" @click="confirmGenerate">确认生成</el-button>
@@ -59,6 +132,8 @@
<script setup name="GenerateBillDialog">
import { getDefaultArBankAccount, previewArBill, generateArBill } from '@/api/ar/bill'
import { Refresh, Search, Select } from '@element-plus/icons-vue'
import { AR_BILL_SELECTION_MODE } from '@/constants/ar/enums'
const props = defineProps({
modelValue: {
@@ -78,14 +153,39 @@ const generateLoading = ref(false)
const previewLoading = ref(false)
const previewSearched = ref(false)
const previewRows = ref([])
const previewTotal = ref(0)
const previewTotalAmount = ref(0)
const previewPageNum = ref(1)
// 主预览列表固定每页 5 条;Map 负责保存跨页勾选结果。
const previewPageSize = 5
const previewTableRef = ref()
const selectedRows = ref([])
const selectedRowMap = new Map()
// 选择模式只保存编码,提交时由后端枚举再次校验。
const selectionMode = ref(AR_BILL_SELECTION_MODE.EXPLICIT)
const excludedRows = ref([])
const excludedRowMap = new Map()
const excludeDialogOpen = ref(false)
const excludeLoading = ref(false)
const excludeKeyword = ref('')
const excludeRowsPage = ref([])
const excludeTotal = ref(0)
const excludePageNum = ref(1)
// 全选后的排除弹窗也按 5 条分页,查询条件固定继承主弹窗条件。
const excludePageSize = 5
const excludeTableRef = ref()
const excludeDraftRows = ref([])
let excludeDraftMap = new Map()
let excludeDialogConfirmed = false
let syncingExcludeSelection = false
const previewQuery = ref(null)
let syncingPageSelection = false
const bankAccountLoading = ref(false)
const bankAccount = ref(null)
const generateRequestId = ref('')
const generateFormRef = ref()
const billNameFormRef = ref()
const generateForm = reactive({ customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' })
const generateForm = reactive({ customerId: '', customerCode: '', customerName: '', cutoffDate: '', currency: 'CNY', orderOrTrackingNo: '' })
const billNameForm = reactive({ billName: '' })
const generateRules = {
customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }],
@@ -94,6 +194,12 @@ const generateRules = {
}
const billNameRules = { billName: [{ required: true, message: '请输入账单名称', trigger: 'blur' }] }
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0))
const excludedAmount = computed(() => excludedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0))
const allSelected = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED)
const displaySelectedCount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? Math.max(previewTotal.value - excludedRows.value.length, 0) : selectedRows.value.length)
const displaySelectedAmount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? Math.max(Number(previewTotalAmount.value || 0) - excludedAmount.value, 0) : selectedAmount.value)
watch(() => props.modelValue, visible => {
if (visible) {
@@ -101,8 +207,9 @@ watch(() => props.modelValue, visible => {
}
})
// 每次打开生成弹窗都重新建立一次查询快照,避免复用上一次的选中订单。
function openGenerate() {
Object.assign(generateForm, { customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' })
Object.assign(generateForm, { customerId: '', customerCode: '', customerName: '', cutoffDate: '', currency: 'CNY', orderOrTrackingNo: '' })
Object.assign(billNameForm, { billName: '' })
billNameOpen.value = false
resetPreview()
@@ -113,6 +220,7 @@ function openGenerate() {
function handleCustomerChange(_, item) {
generateForm.customerCode = item?.customerCode || ''
generateForm.customerName = item?.customerName || ''
resetPreview()
}
@@ -131,9 +239,21 @@ function loadDefaultBankAccount() {
})
}
// 条件变化后清理预览和跨页选中状态;用户点击查询时重新加载第一页。
function resetPreview() {
previewRows.value = []
previewTotal.value = 0
previewTotalAmount.value = 0
previewPageNum.value = 1
selectedRows.value = []
selectedRowMap.clear()
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
excludeDraftRows.value = []
excludeDraftMap = new Map()
excludeDialogOpen.value = false
previewQuery.value = null
previewSearched.value = false
generateRequestId.value = ''
nextTick(() => previewTableRef.value?.clearSelection())
@@ -143,41 +263,225 @@ function buildGenerateQuery() {
return {
customerId: Number(generateForm.customerId),
customerCode: generateForm.customerCode?.trim() || undefined,
customerName: generateForm.customerName?.trim() || undefined,
billPeriodEnd: generateForm.cutoffDate,
currency: generateForm.currency || undefined
currency: generateForm.currency || undefined,
orderOrTrackingNo: generateForm.orderOrTrackingNo?.trim() || undefined
}
}
// 任务请求携带完整筛选快照;全选模式只提交排除 ID,后台按条件游标扫描其余订单。
function buildGenerateRequest(requestId) {
return {
...buildGenerateQuery(),
...(previewQuery.value || buildGenerateQuery()),
billName: billNameForm.billName.trim(),
sourceBillIds: selectedRows.value.map(row => row.billId),
selectionMode: selectionMode.value,
sourceBillIds: selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED ? [] : selectedRows.value.map(row => row.billId),
excludedSourceBillIds: selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED ? Array.from(excludedRowMap.keys()) : [],
requestId
}
}
function buildPreviewQuery(pageNum = previewPageNum.value, pageSize = previewPageSize) {
return { ...(previewQuery.value || buildGenerateQuery()), pageNum, pageSize }
}
// 查询按钮是预览入口,后端返回当前页、总数和全量金额合计。
function queryGeneratePreview() {
generateFormRef.value.validate(valid => {
if (!valid) return
const query = buildGenerateQuery()
resetPreview()
previewLoading.value = true
previewArBill(buildGenerateQuery()).then(res => {
previewRows.value = res.rows || []
previewSearched.value = true
}).finally(() => {
previewLoading.value = false
})
previewQuery.value = query
fetchPreview()
})
}
function fetchPreview() {
previewLoading.value = true
previewArBill(buildPreviewQuery()).then(res => {
syncingPageSelection = true
previewRows.value = res.rows || []
previewTotal.value = Number(res.total) || 0
previewTotalAmount.value = Number(res.totalAmount) || 0
previewSearched.value = true
nextTick(syncPageSelection)
}).finally(() => {
previewLoading.value = false
})
}
function handlePreviewPageChange(page) {
previewPageNum.value = page
fetchPreview()
}
// 当前页变化只更新当前页 ID,selectedRowMap 因此可以跨页保留手动选择。
function handleSelectionChange(rows) {
selectedRows.value = rows
if (syncingPageSelection || selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED) return
const currentPageIds = new Set(previewRows.value.map(row => row.billId))
currentPageIds.forEach(billId => selectedRowMap.delete(billId))
rows.forEach(row => selectedRowMap.set(row.billId, row))
selectedRows.value = Array.from(selectedRowMap.values())
if (!generateLoading.value) generateRequestId.value = ''
}
// 全选后主表复选框全部禁用,用户通过排除弹窗维护“不生成”的订单。
function handleSelectAllChange(checked) {
if (!checked) {
switchToManualMode()
return
}
selectionMode.value = AR_BILL_SELECTION_MODE.ALL_MATCHED
excludedRows.value = []
excludedRowMap.clear()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
nextTick(syncPageSelection)
}
function handleManualModeChange(checked) {
if (checked) switchToManualMode()
}
// 从全选切回手动选择时清除排除集合,恢复主表跨页勾选流程。
function switchToManualMode() {
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
nextTick(syncPageSelection)
}
function isMainRowSelectable() {
return selectionMode.value !== AR_BILL_SELECTION_MODE.ALL_MATCHED
}
function syncExcludedRows() {
excludedRows.value = Array.from(excludedRowMap.values())
}
function syncExcludeDraftRows() {
excludeDraftRows.value = Array.from(excludeDraftMap.values())
}
// 排除弹窗继承客户、币种、截止日和主关键字,只额外允许订单号/运单号 OR 查询。
function openExcludeDialog() {
if (!allSelected.value) return
excludeDialogConfirmed = false
excludeDraftMap = new Map(excludedRowMap)
syncExcludeDraftRows()
excludeKeyword.value = ''
excludePageNum.value = 1
excludeRowsPage.value = []
excludeTotal.value = 0
excludeDialogOpen.value = true
fetchExcludeRows()
}
function buildExcludeQuery() {
return {
...(previewQuery.value || {}),
excludeOrderOrTrackingNo: excludeKeyword.value?.trim() || undefined,
pageNum: excludePageNum.value,
pageSize: excludePageSize
}
}
function queryExcludeRows() {
excludePageNum.value = 1
fetchExcludeRows()
}
function fetchExcludeRows() {
if (!previewQuery.value) return
excludeLoading.value = true
previewArBill(buildExcludeQuery()).then(res => {
syncingExcludeSelection = true
excludeRowsPage.value = res.rows || []
excludeTotal.value = Number(res.total) || 0
nextTick(syncExcludePageSelection)
}).finally(() => {
excludeLoading.value = false
})
}
function handleExcludePageChange(page) {
excludePageNum.value = page
fetchExcludeRows()
}
// 排除弹窗使用独立 Map 保存跨页选择,关闭或翻页不会丢失已勾选订单。
function handleExcludeSelectionChange(rows) {
if (syncingExcludeSelection) return
const currentPageIds = new Set(excludeRowsPage.value.map(row => row.billId))
currentPageIds.forEach(billId => excludeDraftMap.delete(billId))
rows.forEach(row => excludeDraftMap.set(row.billId, row))
syncExcludeDraftRows()
}
function syncExcludePageSelection() {
if (!excludeTableRef.value) {
syncingExcludeSelection = false
return
}
syncingExcludeSelection = true
excludeTableRef.value.clearSelection()
excludeRowsPage.value.forEach(row => {
if (excludeDraftMap.has(row.billId)) excludeTableRef.value.toggleRowSelection(row, true)
})
syncingExcludeSelection = false
}
function confirmExcludeSelection() {
excludedRowMap.clear()
excludeDraftMap.forEach((row, billId) => excludedRowMap.set(billId, row))
syncExcludedRows()
if (!generateLoading.value) generateRequestId.value = ''
excludeDialogConfirmed = true
excludeDialogOpen.value = false
}
function removeExcludedRow(billId) {
excludedRowMap.delete(billId)
excludeDraftMap.delete(billId)
syncExcludeDraftRows()
syncExcludedRows()
if (!generateLoading.value) generateRequestId.value = ''
}
function cancelExcludeSelection() {
excludeDialogConfirmed = false
excludeDialogOpen.value = false
}
function handleExcludeDialogClose() {
if (!excludeDialogConfirmed) {
excludeDraftMap = new Map(excludedRowMap)
syncExcludeDraftRows()
}
excludeDialogConfirmed = false
}
// 翻页后把 Map 中的选择同步回 Element Plus 当前页复选框。
function syncPageSelection() {
if (!previewTableRef.value) {
syncingPageSelection = false
return
}
syncingPageSelection = true
previewTableRef.value.clearSelection()
previewRows.value.forEach(row => {
if (selectedRowMap.has(row.billId)) previewTableRef.value.toggleRowSelection(row, true)
})
syncingPageSelection = false
}
function submitGenerate() {
if (!previewSearched.value || !selectedRows.value.length) {
if (!previewSearched.value || !displaySelectedCount.value) {
proxy.$modal.msgError('请至少选择一条计费订单')
return
}
@@ -186,13 +490,14 @@ function submitGenerate() {
nextTick(() => billNameFormRef.value?.clearValidate())
}
// 最终只提交异步任务,不在浏览器等待几十万条订单明细生成完成。
function confirmGenerate() {
billNameFormRef.value.validate(valid => {
if (!valid) return
if (!generateRequestId.value) generateRequestId.value = createRequestId()
generateLoading.value = true
generateArBill(buildGenerateRequest(generateRequestId.value)).then(res => {
proxy.$modal.msgSuccess('账单生成成功')
proxy.$modal.msgSuccess('账单生成任务已提交,后台正在生成')
billNameOpen.value = false
dialogOpen.value = false
emit('success', res.data)
@@ -219,6 +524,16 @@ function createRequestId() { return globalThis.crypto?.randomUUID?.() || `ar-${D
.preview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 18px 0 10px; padding: 10px 14px; border-radius: 6px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
.preview-summary { flex: 1; font-size: 14px; }
.preview-summary b { color: var(--el-color-primary); }
.preview-selection-toolbar { display: flex; align-items: center; gap: 6px; min-height: 32px; padding-left: 8px; }
.preview-selection-toolbar :deep(.el-checkbox) { margin-right: 6px; }
.preview-selection-toolbar :deep(.el-checkbox__label) { display: inline-flex; align-items: center; padding-left: 4px; font-size: 16px; }
.exclude-order-badge { margin-left: 4px; }
.excluded-order-preview { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; max-height: 64px; margin: 0 0 8px; padding: 4px 8px; overflow-y: auto; border-radius: 4px; background: var(--el-fill-color-light); }
.excluded-order-title { color: var(--el-text-color-secondary); font-size: 13px; }
.exclude-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.exclude-search-input { width: 360px; }
.exclude-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.preview-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.bill-name-summary { margin: -4px 0 8px 90px; color: var(--el-text-color-secondary); font-size: 13px; }
.generate-footer-summary { margin-right: auto; color: var(--el-text-color-secondary); font-size: 13px; }
</style>
+374 -5
View File
@@ -1,5 +1,32 @@
<template>
<div class="app-container ar-bill-page">
<div v-if="generateStatusVisible" class="generate-status-float">
<el-tooltip :content="generateStatusTooltip" placement="bottom">
<el-button class="generate-status-button" type="primary" plain size="small"
:disabled="activeGenerateTask?.status === 'CANCEL_REQUESTED'" @click="cancelActiveGenerateTask">
<span class="generate-progress-ring" :style="{ '--generate-progress': `${generateProgressDegree}deg` }" aria-hidden="true">
<span class="generate-progress-value">{{ generateProgressText }}</span>
</span>
<span class="generate-status-text">
<strong>{{ generateStatusTitle }}</strong>
<small>{{ generateStatusSummary }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<div v-if="exportStatusVisible" class="export-status-float">
<el-tooltip content="点击查看正在生成的导出任务" placement="bottom">
<el-button class="export-status-button" type="primary" plain size="small" @click="openExportTask(activeExportTasks[0])">
<span class="export-progress-ring" aria-hidden="true">
<el-icon class="export-download-icon"><Download /></el-icon>
</span>
<span class="export-status-text">
<strong>导出中</strong>
<small>后台生成中{{ activeExportTasks.length > 1 ? ` · ${activeExportTasks.length}个任务` : '' }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :search-label-width="82" :query="queryList" :default-params="defaultSearchParams" row-key="arBillId" :selection="false">
<template #toolbar>
<el-col :span="1.5"><el-button type="primary" plain icon="Plus" v-hasPermi="['ar:bill:generate']" @click="generateOpen = true">生成账单</el-button></el-col>
@@ -28,7 +55,7 @@
<script setup name="ArBill">
import { saveAs } from 'file-saver'
import { listArBill, getArBill, confirmArBill, voidArBill, exportArBill, downloadArBillExport } from '@/api/ar/bill'
import { listArBill, getArBill, confirmArBill, voidArBill, exportArBill, getArBillExport, downloadArBillExport, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
import BillDetail from './components/BillDetail.vue'
import GenerateBillDialog from './components/GenerateBillDialog.vue'
import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill'
@@ -44,7 +71,52 @@ const defaultSearchParams = computed(() => ({
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const openedBillId = ref()
const generateOpen = ref(false)
// 任务可能在首次轮询前就完成,至少展示一段时间让用户感知到“正在生成”。
const MIN_GENERATE_STATUS_VISIBLE_MS = 1800
// 每个任务独立保存轮询、收尾定时器和开始展示时间,避免多个账单任务互相覆盖。
const generatePollTimers = new Map()
const generateFinishTimers = new Map()
const generateVisibleSince = new Map()
const generateTasks = reactive(new Map())
const activeGenerateTasks = computed(() => Array.from(generateTasks.values()))
const activeGenerateTask = computed(() => activeGenerateTasks.value[0])
const generateStatusVisible = computed(() => activeGenerateTasks.value.length > 0)
const generateStatusTitle = computed(() => {
const status = activeGenerateTask.value?.status
if (status === 'WAITING') return '账单排队中'
if (status === 'CANCEL_REQUESTED') return '账单取消中'
return '账单生成中'
})
const generateStatusTooltip = computed(() => {
return activeGenerateTask.value?.status === 'CANCEL_REQUESTED'
? '正在清理已生成的账单数据,请稍候'
: '点击取消当前账单生成任务'
})
const generateProgressText = computed(() => {
const task = activeGenerateTask.value
if (!task || Number(task.totalCount) <= 0) return '…'
return `${Math.min(100, Math.floor(Number(task.processedCount || 0) * 100 / Number(task.totalCount)))}%`
})
const generateProgressDegree = computed(() => {
const task = activeGenerateTask.value
if (!task || Number(task.totalCount) <= 0) return 45
return Math.max(0, Math.min(360, Number(task.processedCount || 0) * 360 / Number(task.totalCount)))
})
const generateStatusSummary = computed(() => {
const task = activeGenerateTask.value
if (!task) return '任务提交中'
if (task.status === 'CANCEL_REQUESTED') return '正在清理数据'
if (task.status === 'WAITING') return '等待后台处理'
if (Number(task.totalCount) > 0) return `${Number(task.processedCount || 0)} / ${Number(task.totalCount)} 条`
return '正在准备数据'
})
const exportPollTimers = new Map()
const exportTasks = reactive(new Map())
const activeExportTasks = computed(() => Array.from(exportTasks.values()))
const exportSubmittingCount = ref(0)
const exportStatusVisible = computed(() => exportSubmittingCount.value > 0 || activeExportTasks.value.length > 0)
const statusOptions = AR_BILL_STATUS_OPTIONS
const customerScoped = computed(() => Boolean(route.query.customerId))
const columns = [
@@ -59,12 +131,167 @@ const searchColumns = [
{ label: '状态', prop: 'status', component: 'select', options: statusOptions, placeholder: '全部状态', width: '150px' }, { label: '账单日期', prop: 'billDateRange', component: 'daterange', width: '300px' }, { label: '账单截止日', prop: 'billPeriodEnd', component: 'date', width: '160px' }
]
// 将页面日期范围转换为后端查询字段,其他筛选条件原样透传。
function queryList(params) { const query = { ...params }; if (query.billDateRange?.length === 2) { query.billDateStart = query.billDateRange[0]; query.billDateEnd = query.billDateRange[1] }; delete query.billDateRange; return listArBill(query) }
function handleGenerateSuccess(bill) { tableRef.value.refresh(); viewBill(bill) }
function viewBill(row) { if (!row?.arBillId) return; detailOpen.value = true; detailLoading.value = true; detail.value = null; getArBill(row.arBillId).then(res => { detail.value = res.data || null }).finally(() => { detailLoading.value = false }) }
// 生成弹窗只提交任务,账单明细由后台异步批量写入;这里负责刷新列表并开始轮询。
function handleGenerateSuccess(task) {
tableRef.value?.refresh()
if (!task?.taskId) return
generateVisibleSince.set(task.taskId, Date.now())
generateTasks.set(task.taskId, task)
startGeneratePolling(task.taskId)
}
function startGeneratePolling(taskId) {
stopGeneratePolling(taskId)
// WAITING/RUNNING/CANCEL_REQUESTED 都属于前端持续关注的中间状态。
const poll = () => {
getArBillGenerateTask(taskId).then(res => {
const task = res.data
if (!task) return
generateTasks.set(taskId, task)
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(task.status)) {
finishGenerateTask(taskId, task)
return
}
generatePollTimers.set(taskId, window.setTimeout(poll, 2000))
}).catch(() => {
generatePollTimers.set(taskId, window.setTimeout(poll, 3000))
})
}
generatePollTimers.set(taskId, window.setTimeout(poll, 1000))
}
function finishGenerateTask(taskId, task) {
stopGeneratePolling(taskId)
if (generateFinishTimers.has(taskId)) return
// 即使后台很快返回 SUCCESS,也延迟清理浮层,保证用户能看到结果反馈。
const visibleSince = generateVisibleSince.get(taskId) || Date.now()
const remaining = Math.max(0, MIN_GENERATE_STATUS_VISIBLE_MS - (Date.now() - visibleSince))
const timer = window.setTimeout(() => {
generateFinishTimers.delete(taskId)
generateVisibleSince.delete(taskId)
generateTasks.delete(taskId)
tableRef.value?.refresh()
if (task.status === 'SUCCESS') {
proxy.$modal.msgSuccess('账单生成成功')
if (task.arBillId) viewBill({ arBillId: task.arBillId })
} else if (task.status === 'FAILED') {
proxy.$modal.confirm(`账单生成失败:${task.errorMessage || '未知错误'},是否重新生成?`).then(() => retryGenerateTask(task.taskId)).then(res => {
const retryTask = res.data
if (retryTask?.taskId) {
generateVisibleSince.set(retryTask.taskId, Date.now())
generateTasks.set(retryTask.taskId, retryTask)
startGeneratePolling(retryTask.taskId)
}
}).catch(() => {})
} else {
proxy.$modal.msgWarning('账单生成任务已取消')
}
}, remaining)
generateFinishTimers.set(taskId, timer)
}
function stopGeneratePolling(taskId) {
const timer = generatePollTimers.get(taskId)
if (timer) window.clearTimeout(timer)
generatePollTimers.delete(taskId)
}
function cancelActiveGenerateTask() {
const task = activeGenerateTask.value
if (!task?.taskId) return
proxy.$modal.confirm('账单正在后台生成,确定取消当前任务吗?').then(() => cancelArBillGenerateTask(task.taskId)).then(res => {
const updated = res.data
if (updated) generateTasks.set(updated.taskId, updated)
proxy.$modal.msgSuccess('已提交取消请求')
}).catch(() => {})
}
function loadActiveGenerateTasks() {
listActiveArBillGenerateTasks().then(res => {
;(res.data || []).forEach(task => {
generateTasks.set(task.taskId, task)
if (!generateVisibleSince.has(task.taskId)) generateVisibleSince.set(task.taskId, Date.now())
startGeneratePolling(task.taskId)
})
}).catch(() => {})
}
let detailRequestSerial = 0
function viewBill(row) {
if (!row?.arBillId) return
const serial = ++detailRequestSerial
openedBillId.value = row.arBillId
detailOpen.value = true
detailLoading.value = true
detail.value = null
getArBill(row.arBillId).then(res => {
if (serial === detailRequestSerial) detail.value = res.data || null
}).finally(() => {
if (serial === detailRequestSerial) detailLoading.value = false
})
}
function confirmBill(row) { proxy.$modal.confirm(`确认账单「${row.billNo}」?确认后将进入正式账单状态,请确认订单及费用金额无误后继续。`).then(() => confirmArBill(row.arBillId)).then(() => { proxy.$modal.msgSuccess('账单已确认'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
function voidBill(row) { proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' }).then(({ value }) => voidArBill(row.arBillId, { remark: value })).then(() => { proxy.$modal.msgSuccess('账单已作废'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
function exportBill(row) { if (!row?.arBillId) return; exportArBill(row.arBillId).then(res => { proxy.$modal.msgSuccess('导出成功'); tableRef.value.refresh(); if (detailOpen.value) viewBill(row); if (res.data?.exportId) downloadExport(res.data) }) }
function exportBill(row) {
if (!row?.arBillId) return
exportSubmittingCount.value += 1
exportArBill(row.arBillId).then(res => {
const exportTask = res.data
tableRef.value?.refresh()
// 导出同样是异步任务;打开详情展示“生成中”状态及完成后的下载入口。
viewBill(row)
if (exportTask?.exportId) {
exportTasks.set(exportTask.exportId, exportTask)
startExportPolling(exportTask.exportId)
}
}).finally(() => {
exportSubmittingCount.value = Math.max(0, exportSubmittingCount.value - 1)
})
}
onMounted(loadActiveGenerateTasks)
function startExportPolling(exportId) {
stopExportPolling(exportId)
// 导出文件在后台临时目录生成,前端只轮询状态,不等待文件内容返回。
const poll = () => {
getArBillExport(exportId).then(res => {
const exportTask = res.data
if (!exportTask || exportTask.exportStatus === 'PROCESSING') {
if (exportTask) exportTasks.set(exportId, exportTask)
exportPollTimers.set(exportId, window.setTimeout(poll, 1500))
return
}
stopExportPolling(exportId)
exportTasks.delete(exportId)
tableRef.value?.refresh()
if (detailOpen.value && openedBillId.value === exportTask.arBillId) viewBill(exportTask)
if (exportTask.exportStatus === 'SUCCESS') {
proxy.$modal.msgSuccess('导出文件已生成,可在导出历史中下载')
} else {
proxy.$modal.msgError(`账单导出失败:${exportTask.remark || '请重新发起导出'}`)
}
}).catch(() => {
stopExportPolling(exportId)
exportTasks.delete(exportId)
})
}
exportPollTimers.set(exportId, window.setTimeout(poll, 1500))
}
function stopExportPolling(exportId) {
const timer = exportPollTimers.get(exportId)
if (timer) window.clearTimeout(timer)
exportPollTimers.delete(exportId)
}
function openExportTask(task) {
if (!task?.arBillId) return
viewBill({ arBillId: task.arBillId })
}
// 仅下载 SUCCESS 状态的导出任务,文件名优先使用后端保存的名称。
function downloadExport(row) { downloadArBillExport(row.exportId).then(blob => saveAs(blob, row.fileName || `账单_${row.exportId}.xlsx`)) }
function clearCustomerFilter() {
const query = { ...route.query }
@@ -72,7 +299,7 @@ function clearCustomerFilter() {
router.replace({ path: route.path, query })
}
// 从客户余额页重复进入不同客户时,路由组件会被复用,需同步更新表格内部查询条件
// 从客户余额页重复进入不同客户时,路由组件会被复用,需同步更新表格内部查询条件。
watch(() => route.query.customerId, (newId, oldId) => {
if (String(newId || '') === String(oldId || '')) return
tableRef.value?.setQueryParams({
@@ -82,6 +309,18 @@ watch(() => route.query.customerId, (newId, oldId) => {
tableRef.value?.handleQuery()
})
// 页面销毁时清理所有轮询定时器,避免切换菜单后仍访问旧页面状态。
onBeforeUnmount(() => {
generatePollTimers.forEach(timer => window.clearTimeout(timer))
generatePollTimers.clear()
generateFinishTimers.forEach(timer => window.clearTimeout(timer))
generateFinishTimers.clear()
generateVisibleSince.clear()
exportPollTimers.forEach(timer => window.clearTimeout(timer))
exportPollTimers.clear()
exportTasks.clear()
})
function canGoWriteoff(row) {
if (!row?.customerId) return false
const canWriteoffStatus = row.status === AR_BILL_STATUS.CONFIRMED
@@ -152,6 +391,136 @@ function statusType(value) { return AR_BILL_STATUS_TYPES[value] || 'info' }
.ar-bill-page .written { color: #67c23a; }
.ar-bill-page .unwritten { color: #e6a23c; }
.ar-bill-page .generate-status-float {
position: fixed;
top: 92px;
right: 24px;
z-index: 3001;
}
.ar-bill-page .generate-status-button {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 184px;
min-height: 68px;
padding: 6px 16px 6px 7px;
border-radius: 36px;
}
.ar-bill-page .generate-progress-ring {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 54px;
width: 54px;
height: 54px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-primary) 0deg var(--generate-progress, 45deg),
var(--el-color-primary-light-8) var(--generate-progress, 45deg) 360deg
);
}
.ar-bill-page .generate-progress-ring::after {
position: absolute;
inset: 5px;
content: '';
border-radius: 50%;
background: var(--el-bg-color);
}
.ar-bill-page .generate-progress-value {
position: relative;
z-index: 1;
color: var(--el-color-primary);
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.ar-bill-page .generate-status-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
line-height: 1.2;
white-space: nowrap;
}
.ar-bill-page .generate-status-text strong { font-size: 14px; font-weight: 600; }
.ar-bill-page .generate-status-text small { color: var(--el-text-color-secondary); font-size: 11px; }
.ar-bill-page .export-status-float {
position: fixed;
top: 170px;
right: 24px;
z-index: 3001;
}
.ar-bill-page .export-status-button {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 164px;
min-height: 62px;
border-radius: 32px;
padding: 6px 16px 6px 7px;
}
.ar-bill-page .export-progress-ring {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 48px;
width: 48px;
height: 48px;
border-radius: 50%;
background: conic-gradient(var(--el-color-primary) 0deg 285deg, var(--el-color-primary-light-8) 285deg 360deg);
animation: ar-bill-export-spin 1.2s linear infinite;
}
.ar-bill-page .export-progress-ring::after {
position: absolute;
inset: 5px;
content: '';
border-radius: 50%;
background: var(--el-bg-color);
}
.ar-bill-page .export-download-icon {
position: relative;
z-index: 1;
color: var(--el-color-primary);
font-size: 21px;
}
.ar-bill-page .export-status-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
line-height: 1.2;
white-space: nowrap;
}
.ar-bill-page .export-status-text strong {
font-size: 14px;
font-weight: 600;
}
.ar-bill-page .export-status-text small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
@keyframes ar-bill-export-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 1200px) {
.ar-bill-page :deep(.basic-table > .basic-form) {
grid-template-columns: repeat(2, minmax(0, 1fr));
+2
View File
@@ -19,12 +19,14 @@ import {
} from '@/constants/ar/enums'
const tableRef = ref()
// 日志筛选值与后端枚举保持一致,展示文案统一由枚举工具转换。
const searchColumns = [
{ label: '对象类型', prop: 'objectType', component: 'select', options: AR_LOG_OBJECT_TYPE_OPTIONS, width: '130px' },
{ label: '操作类型', prop: 'operationType', component: 'select', options: AR_LOG_OPERATION_TYPE_OPTIONS, width: '150px' },
{ label: '操作来源', prop: 'operationSource', component: 'select', options: AR_LOG_OPERATION_SOURCE_OPTIONS, width: '130px' },
{ label: '结果', prop: 'result', component: 'select', options: AR_LOG_RESULT_OPTIONS, width: '120px' }
]
// 状态变化列同时展示前后状态,便于追踪确认、作废和核销等生命周期操作。
const columns = [
{ label: '对象类型', prop: 'objectType', width: 110, formatter: row => objectTypeText(row.objectType) }, { label: '对象', prop: 'objectDisplay', minWidth: 220, showOverflowTooltip: true, formatter: row => row.objectDisplay || '-' },
{ label: '操作', prop: 'operationType', width: 150, formatter: row => operationText(row.operationType) }, { label: '说明', prop: 'operationDesc', minWidth: 220, showOverflowTooltip: true },