607 lines
25 KiB
Vue
607 lines
25 KiB
Vue
<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="!canCancelActiveGenerateTask" @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>
|
||
<el-col v-if="customerScoped" :span="1.5"><el-button type="info" plain icon="Close" @click="clearCustomerFilter">清除客户条件</el-button></el-col>
|
||
</template>
|
||
<template #search-customerId="{ model }">
|
||
<customer-select v-model="model.customerId" placeholder="请选择客户" :page-size="1000" />
|
||
</template>
|
||
<template #col-status="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
|
||
<template #col-totalAmount="{ row }"><span class="amount">{{ money(row.totalAmount) }}</span></template>
|
||
<template #col-writtenOffAmount="{ row }"><span class="written">{{ money(row.writtenOffAmount) }}</span></template>
|
||
<template #col-unwrittenAmount="{ row }"><span class="unwritten">{{ money(row.unwrittenAmount) }}</span></template>
|
||
<template #col-action="{ row }">
|
||
<el-button link type="primary" icon="View" @click="viewBill(row)">详情</el-button>
|
||
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT" link type="success" v-hasPermi="['ar:bill:confirm']" @click="confirmBill(row)">确认</el-button>
|
||
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT || row.status === AR_BILL_STATUS.CONFIRMED" link type="warning" v-hasPermi="['ar:bill:void']" @click="voidBill(row)">作废</el-button>
|
||
<el-button v-if="row.status !== AR_BILL_STATUS.DRAFT && row.status !== AR_BILL_STATUS.VOID" link type="primary" v-hasPermi="['ar:bill:export']" @click="exportBill(row)">导出</el-button>
|
||
<el-button v-if="canGoWriteoff(row)" link type="primary" v-hasPermi="['customer:bill:writeoff']" @click="goWriteoff(row)">去核销</el-button>
|
||
</template>
|
||
</basic-table>
|
||
|
||
<bill-detail v-model="detailOpen" :detail="detail" :loading="detailLoading" @download="downloadExport" @export="exportBill(detail)" />
|
||
<generate-bill-dialog v-model="generateOpen" @success="handleGenerateSuccess" />
|
||
<el-dialog v-model="cancelConfirmVisible" title="取消账单生成" width="420px" append-to-body
|
||
:close-on-click-modal="false" :close-on-press-escape="!cancelConfirmLoading"
|
||
:show-close="!cancelConfirmLoading" @closed="handleCancelConfirmClosed">
|
||
<p class="cancel-generate-message">账单正在后台生成,确定取消当前任务吗?</p>
|
||
<template #footer>
|
||
<el-button :disabled="cancelConfirmLoading" @click="cancelConfirmVisible = false">暂不取消</el-button>
|
||
<el-button type="warning" :loading="cancelConfirmLoading" :disabled="cancelConfirmDisabled"
|
||
@click="confirmCancelActiveGenerateTask">{{ cancelConfirmLoading ? '取消中' : '确定取消' }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup name="ArBill">
|
||
import { saveAs } from 'file-saver'
|
||
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'
|
||
|
||
const { proxy } = getCurrentInstance()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const tableRef = ref()
|
||
// 从客户余额页“查看账单”跳入时,按当前客户预筛选(重置后仍保留该条件)
|
||
const defaultSearchParams = computed(() => ({
|
||
customerId: route.query.customerId ? Number(route.query.customerId) : undefined
|
||
}))
|
||
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 cancelConfirmVisible = ref(false)
|
||
const cancelConfirmLoading = ref(false)
|
||
const cancelConfirmTaskId = ref()
|
||
const cancelConfirmTask = computed(() => cancelConfirmTaskId.value ? generateTasks.get(cancelConfirmTaskId.value) : null)
|
||
const canCancelActiveGenerateTask = computed(() => ['WAITING', 'RUNNING'].includes(activeGenerateTask.value?.status))
|
||
const cancelConfirmDisabled = computed(() => {
|
||
return cancelConfirmLoading.value || !['WAITING', 'RUNNING'].includes(cancelConfirmTask.value?.status)
|
||
})
|
||
const generateStatusTitle = computed(() => {
|
||
const status = activeGenerateTask.value?.status
|
||
if (status === 'WAITING') return '账单排队中'
|
||
if (status === 'SUCCESS') return '账单生成完成'
|
||
if (status === 'FAILED') return '账单生成失败'
|
||
if (status === 'CANCELED') return '账单已取消'
|
||
if (status === 'CANCEL_REQUESTED') return '账单取消中'
|
||
return '账单生成中'
|
||
})
|
||
const generateStatusTooltip = computed(() => {
|
||
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(activeGenerateTask.value?.status)) {
|
||
return '任务状态已更新'
|
||
}
|
||
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 === 'FAILED') return '任务执行失败'
|
||
if (task.status === 'CANCELED') 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 = [
|
||
{ label: '账单号', prop: 'billNo', minWidth: 180, showOverflowTooltip: true }, { label: '账单名称', prop: 'billName', minWidth: 180, showOverflowTooltip: true }, { label: '客户名称', prop: 'customerName', minWidth: 130, showOverflowTooltip: true }, { label: '客户编码', prop: 'customerCode', minWidth: 120, showOverflowTooltip: true },
|
||
{ label: '账单截止日', prop: 'billPeriodEnd', minWidth: 120, formatter: row => `截至 ${date(row.billPeriodEnd)}` }, { label: '订单数', prop: 'orderCount', width: 80 },
|
||
{ label: '应收总额', prop: 'totalAmount', width: 120, align: 'right', slot: 'totalAmount' }, { label: '已核销', prop: 'writtenOffAmount', width: 110, align: 'right', slot: 'writtenOffAmount' },
|
||
{ label: '未核销', prop: 'unwrittenAmount', width: 110, align: 'right', slot: 'unwrittenAmount' }, { label: '状态', prop: 'status', width: 110, slot: 'status' },
|
||
{ label: '账单日期', prop: 'billDate', width: 120, formatter: row => date(row.billDate) }, { label: '操作', prop: 'action', width: 330, fixed: 'right', slot: 'action', showInToolbar: false }
|
||
]
|
||
const searchColumns = [
|
||
{ label: '账单号', prop: 'billNo', component: 'input', placeholder: '账单号', width: '180px' }, { label: '账单名称', prop: 'billName', component: 'input', placeholder: '账单名称', width: '180px' }, { label: '客户名称', prop: 'customerId', component: 'input', placeholder: '请选择客户', width: '220px' }, { label: '客户编码', prop: 'customerCode', component: 'input', placeholder: '客户编码', width: '140px' },
|
||
{ 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(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)
|
||
closeCancelConfirm(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 || !canCancelActiveGenerateTask.value) return
|
||
cancelConfirmTaskId.value = task.taskId
|
||
cancelConfirmLoading.value = false
|
||
cancelConfirmVisible.value = true
|
||
}
|
||
|
||
async function confirmCancelActiveGenerateTask() {
|
||
const taskId = cancelConfirmTaskId.value
|
||
const task = taskId ? generateTasks.get(taskId) : null
|
||
if (!taskId || !['WAITING', 'RUNNING'].includes(task?.status)) {
|
||
cancelConfirmVisible.value = false
|
||
return
|
||
}
|
||
cancelConfirmLoading.value = true
|
||
try {
|
||
const res = await cancelArBillGenerateTask(taskId)
|
||
const updated = res.data
|
||
let cancellationSubmitted = true
|
||
if (updated?.taskId) {
|
||
generateTasks.set(updated.taskId, updated)
|
||
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(updated.status)) {
|
||
finishGenerateTask(updated.taskId, updated)
|
||
cancellationSubmitted = false
|
||
}
|
||
}
|
||
cancelConfirmVisible.value = false
|
||
if (cancellationSubmitted) proxy.$modal.msgSuccess('已提交取消请求')
|
||
} catch (error) {
|
||
// 请求失败时保留弹窗,允许用户重新确认;全局请求拦截器负责展示错误信息。
|
||
} finally {
|
||
cancelConfirmLoading.value = false
|
||
}
|
||
}
|
||
|
||
function closeCancelConfirm(taskId) {
|
||
if (cancelConfirmTaskId.value !== taskId) return
|
||
cancelConfirmVisible.value = false
|
||
cancelConfirmLoading.value = false
|
||
cancelConfirmTaskId.value = undefined
|
||
}
|
||
|
||
function handleCancelConfirmClosed() {
|
||
cancelConfirmLoading.value = false
|
||
cancelConfirmTaskId.value = undefined
|
||
}
|
||
|
||
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
|
||
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 }
|
||
delete query.customerId
|
||
router.replace({ path: route.path, query })
|
||
}
|
||
|
||
// 从客户余额页重复进入不同客户时,路由组件会被复用,需同步更新表格内部查询条件。
|
||
watch(() => route.query.customerId, (newId, oldId) => {
|
||
if (String(newId || '') === String(oldId || '')) return
|
||
tableRef.value?.setQueryParams({
|
||
customerId: newId ? Number(newId) : undefined,
|
||
pageNum: 1
|
||
})
|
||
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
|
||
|| row.status === AR_BILL_STATUS.PARTIAL_WRITTEN_OFF
|
||
return canWriteoffStatus && Number(row.unwrittenAmount) > 0
|
||
}
|
||
function goWriteoff(row) {
|
||
if (!canGoWriteoff(row)) return
|
||
router.push({
|
||
path: '/customersupplier/customer-money/index',
|
||
query: {
|
||
customerId: row.customerId,
|
||
customerName: row.customerName || '',
|
||
customerCode: row.customerCode || '',
|
||
tab: 'billWriteoff'
|
||
}
|
||
})
|
||
}
|
||
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 statusOptions.find(item => item.value === value)?.label || value || '-' }
|
||
function statusType(value) { return AR_BILL_STATUS_TYPES[value] || 'info' }
|
||
</script>
|
||
|
||
<style scoped>
|
||
.ar-bill-page :deep(.basic-table > .basic-form) {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 14px 20px;
|
||
align-items: center;
|
||
padding: 16px 18px 4px;
|
||
margin-bottom: 18px;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 8px;
|
||
background: var(--el-bg-color);
|
||
}
|
||
|
||
.ar-bill-page :deep(.basic-table > .basic-form > .el-form-item) {
|
||
min-width: 0;
|
||
margin-right: 0;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.ar-bill-page :deep(.basic-table > .basic-form > .el-form-item:not(:last-child) .el-form-item__content) {
|
||
min-width: 0;
|
||
}
|
||
|
||
.ar-bill-page :deep(.basic-table > .basic-form > .el-form-item:not(:last-child) .el-form-item__content > *) {
|
||
width: 100% !important;
|
||
}
|
||
|
||
.ar-bill-page :deep(.basic-table > .basic-form > .el-form-item:last-child) {
|
||
grid-column: 1 / -1;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.ar-bill-page :deep(.basic-table > .basic-form > .el-form-item:last-child .el-form-item__content) {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.ar-bill-page :deep(.basic-table > .basic-form > .el-form-item:last-child .el-button) {
|
||
min-width: 88px;
|
||
margin-left: 0;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.ar-bill-page .amount { color: #f56c6c; font-weight: 600; }
|
||
.ar-bill-page .written { color: #67c23a; }
|
||
.ar-bill-page .unwritten { color: #e6a23c; }
|
||
.ar-bill-page .cancel-generate-message {
|
||
margin: 0;
|
||
color: var(--el-text-color-primary);
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.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));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.ar-bill-page :deep(.basic-table > .basic-form) {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
padding: 14px 12px 4px;
|
||
}
|
||
}
|
||
</style>
|