Files
BMS/bms-front/src/views/ar/bill/index.vue
T

607 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>