增加导出功能

This commit is contained in:
wuweihua
2026-10-21 18:06:21 +08:00
parent c04191c316
commit 92000956fc
15 changed files with 1181 additions and 21 deletions
+238
View File
@@ -0,0 +1,238 @@
import { download } from '@/utils/request'
import request from '@/utils/request'
import { ElLoading, ElMessage } from 'element-plus'
import { saveAs } from 'file-saver'
const DEFAULT_IGNORE_KEYS = ['pageNum', 'pageSize', 'orderByColumn', 'isAsc', 'reasonable']
/**
* Sync list export: POST filters and download xlsx (most existing pages).
* @param {string} url e.g. 'system/user/export'
* @param {object} [query]
* @param {string} [filePrefix='export']
*/
export function exportExcel(url, query, filePrefix = 'export') {
const name = String(filePrefix || 'export') + '_' + Date.now() + '.xlsx'
return download(url, { ...(query || {}) }, name)
}
/**
* Sync export from basic-table current query.
*/
export function exportTable(url, tableRef, normalize, filePrefix = 'export') {
const raw = (tableRef && tableRef.getQueryParams && tableRef.getQueryParams()) || {}
const query = typeof normalize === 'function' ? normalize(raw) : raw
return exportExcel(url, query, filePrefix)
}
function isFilled(value) {
if (value == null) return false
if (typeof value === 'string') return value.trim() !== ''
if (typeof value === 'number' || typeof value === 'boolean') return true
if (Array.isArray(value)) return value.some(v => isFilled(v))
if (value instanceof Date) return !Number.isNaN(value.getTime())
if (typeof value === 'object') {
return Object.keys(value).some(k => isFilled(value[k]))
}
return false
}
/**
* Generic filter gate for list export.
* Default: any non-empty field except page/sort keys counts.
*
* @param {object} query
* @param {object} [opts]
* @param {string[]} [opts.ignoreKeys] keys that never count as filter
* @param {string[]} [opts.requireAny] if set, only these keys (and nested path) count
* @param {(q: object) => boolean} [opts.custom] custom predicate; wins when provided
*/
export function hasListFilter(query, opts = {}) {
if (typeof opts.custom === 'function') return !!opts.custom(query)
if (!query || typeof query !== 'object') return false
const ignore = new Set([...(opts.ignoreKeys || []), ...DEFAULT_IGNORE_KEYS])
const requireAny = Array.isArray(opts.requireAny) && opts.requireAny.length
? opts.requireAny
: null
const keys = requireAny || Object.keys(query)
for (const key of keys) {
if (ignore.has(key)) continue
if (isFilled(query[key])) return true
}
return false
}
/** @deprecated use hasListFilter */
export function hasExportFilter(q, opts = {}) {
const ignoreKeys = [...(opts.ignoreKeys || [])]
if (opts.ignoreOrderStatuses !== false) ignoreKeys.push('orderStatuses')
return hasListFilter(q, { ...opts, ignoreKeys })
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function normalizeBaseUrl(url) {
const s = String(url || '').trim()
if (!s) return ''
return s.startsWith('/') ? s.replace(/\/+$/, '') : ('/' + s.replace(/\/+$/, ''))
}
function resolveAsyncUrls(options) {
const base = normalizeBaseUrl(options.baseUrl || options.submitUrl || '')
const submitUrl = normalizeBaseUrl(options.submitUrl || base)
const taskUrlPrefix = options.taskUrlPrefix
|| (base ? base + '/task/' : '')
const downloadUrlPrefix = options.downloadUrlPrefix
|| (base ? base + '/download/' : '')
return { submitUrl, taskUrlPrefix, downloadUrlPrefix }
}
function pickTaskId(payload) {
if (payload == null) return null
if (typeof payload === 'string' || typeof payload === 'number') return String(payload)
return payload.taskId || payload.exportId || payload.id || null
}
function taskDone(task, options) {
const status = String(task && task.status != null ? task.status : (task && task.exportStatus) || '').toUpperCase()
const successValues = options.successStatus || ['SUCCESS']
const failValues = options.failStatus || ['FAIL', 'FAILED']
if (successValues.map(v => String(v).toUpperCase()).includes(status)) return 'SUCCESS'
if (failValues.map(v => String(v).toUpperCase()).includes(status)) return 'FAIL'
return 'RUNNING'
}
/**
* Async export common flow: submit -> poll -> download.
* Backend convention (recommended):
* POST {baseUrl} -> { taskId }
* GET {baseUrl}/task/{id} -> task progress
* GET {baseUrl}/download/{id}-> file blob
*
* @param {object} options
* @param {string} [options.baseUrl] preferred; derives task/download urls
* @param {string} [options.submitUrl]
* @param {string} [options.taskUrlPrefix]
* @param {string} [options.downloadUrlPrefix]
* @param {object} [options.query]
* @param {boolean} [options.requireFilter=false]
* @param {object} [options.filterOpts] passed to hasListFilter
* @param {string} [options.filterMessage]
* @param {string} [options.filePrefix='export']
* @param {string} [options.loadingText]
* @param {(task: object) => void} [options.onProgress]
* @param {number} [options.pollIntervalMs=800]
* @param {number} [options.maxWaitMs]
* @param {string[]} [options.successStatus]
* @param {string[]} [options.failStatus]
* @param {boolean} [options.showSuccessMessage=true]
*/
export async function exportAsync(options = {}) {
const { submitUrl, taskUrlPrefix, downloadUrlPrefix } = resolveAsyncUrls(options)
if (!submitUrl || !taskUrlPrefix || !downloadUrlPrefix) {
throw new Error('exportAsync requires baseUrl or submit/task/download urls')
}
const query = { ...(options.query || {}) }
delete query.pageNum
delete query.pageSize
if (options.requireFilter) {
const extraIgnore = (options.filterOpts && options.filterOpts.ignoreKeys) || []
const filterOpts = {
...(options.filterOpts || {}),
ignoreKeys: ['orderStatuses', ...extraIgnore]
}
if (!hasListFilter(query, filterOpts)) {
ElMessage.warning(options.filterMessage || '\u8bf7\u5148\u8bbe\u7f6e\u5217\u8868\u67e5\u8be2\u6761\u4ef6\u540e\u518d\u5bfc\u51fa')
return null
}
}
const filePrefix = options.filePrefix || 'export'
const loadingText = options.loadingText || '\u6b63\u5728\u5bfc\u51fa\uff0c\u8bf7\u7a0d\u5019'
const pollIntervalMs = options.pollIntervalMs || 800
const maxWaitMs = options.maxWaitMs || 30 * 60 * 1000
const showSuccessMessage = options.showSuccessMessage !== false
const loading = ElLoading.service({ text: loadingText, background: 'rgba(0, 0, 0, 0.7)' })
try {
const submitRes = await request({
url: submitUrl,
method: 'post',
data: query,
timeout: 120000,
headers: { repeatSubmit: false }
})
const taskId = pickTaskId(submitRes && submitRes.data)
if (!taskId) throw new Error('missing export taskId')
const started = Date.now()
let task = null
while (Date.now() - started < maxWaitMs) {
const res = await request({ url: taskUrlPrefix + String(taskId), method: 'get' })
task = res.data || {}
if (typeof options.onProgress === 'function') options.onProgress(task)
const pct = Number(task.percent)
if (!Number.isNaN(pct) && pct >= 0) {
loading.setText(loadingText + ' ' + Math.min(100, pct) + '%')
}
const state = taskDone(task, options)
if (state === 'SUCCESS') break
if (state === 'FAIL') {
throw new Error(task.error || task.remark || task.message || 'export failed')
}
await sleep(pollIntervalMs)
}
if (!task || taskDone(task, options) !== 'SUCCESS') {
throw new Error('export timeout')
}
const file = await request({
url: downloadUrlPrefix + String(taskId),
method: 'get',
responseType: 'blob',
timeout: 120000
})
if (file && file.type && String(file.type).includes('json')) {
const error = JSON.parse(await file.text())
throw new Error(error.msg || 'download export file failed')
}
const name = task.fileName || (filePrefix + '_' + Date.now() + '.xlsx')
saveAs(new Blob([file]), name)
if (showSuccessMessage) ElMessage.success('\u5bfc\u51fa\u6210\u529f')
return { task, taskId }
} catch (e) {
ElMessage.error((e && e.message) || '\u5bfc\u51fa\u5931\u8d25')
return null
} finally {
loading.close()
}
}
/**
* Async export from basic-table.
* @param {object} options same as exportAsync + tableRef/normalize
*/
export async function exportAsyncFromTable(options = {}) {
const raw = (options.tableRef && options.tableRef.getQueryParams && options.tableRef.getQueryParams()) || {}
const query = typeof options.normalize === 'function' ? options.normalize({ ...raw }) : { ...raw }
delete query.pageNum
delete query.pageSize
return exportAsync({
...options,
query,
requireFilter: options.requireFilter !== false
})
}
/** @deprecated use exportAsyncFromTable */
export async function exportTableAsyncFromRef(options = {}) {
return exportAsyncFromTable(options)
}
/** @deprecated use exportAsync */
export async function exportTableAsync(options = {}) {
return exportAsync(options)
}
+27 -2
View File
@@ -117,6 +117,15 @@
@click="openRecalcDialog"
>重计费</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="Download"
v-hasPermi="['price:billing:export']"
@click="handleExport"
>导出</el-button>
</el-col>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="handleView(row)">查看</el-button>
@@ -225,6 +234,7 @@
<script setup name="BillingBill">
import { listBill, getBill, refundBill, listOrderNoOptions, listTrackingNoOptions, listTransferNoOptions, updateCreditArrears, importChargeWeightAsync, downloadChargeWeightTemplate, recalcBillPriceAsync } from '@/api/price/billing'
import { exportAsyncFromTable } from '@/utils/excelExport'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useProducts } from '@/components/ProductSelect/useProducts'
import BillDetail from './components/BillDetail.vue'
@@ -390,8 +400,8 @@ const searchColumns = [
}
]
function queryList(params) {
const { bizDateRange, ...rest } = params
function buildBillQuery(params) {
const { bizDateRange, ...rest } = params || {}
const query = { ...rest }
if (Array.isArray(bizDateRange) && (bizDateRange[0] || bizDateRange[1])) {
query.params = {
@@ -400,9 +410,24 @@ function queryList(params) {
endBizDate: bizDateRange[1] || null
}
}
return query
}
function queryList(params) {
const query = buildBillQuery(params)
return listBill(query)
}
function handleExport() {
return exportAsyncFromTable({
baseUrl: '/price/billing/bill/export',
tableRef: tableRef.value,
normalize: buildBillQuery,
filePrefix: 'billing_bill',
requireFilter: true
})
}
function handleUpdateArrears(ids, selection) {
const billIds = (ids && ids.length)
? ids
@@ -38,6 +38,15 @@
<el-col :span="1.5">
<el-button type="info" plain icon="Document" @click="logDrawerRef.open()">操作记录</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="Download"
v-hasPermi="['price:orderBill:export']"
@click="handleExport"
>导出</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
@@ -129,6 +138,7 @@
<script setup name="OrderBillQuery">
import { ElMessage, ElMessageBox } from 'element-plus'
import { listOrderBill, listOrderNoOptions, listTrackingNoOptions, listTransferNoOptions, localRebillAsync } from '@/api/price/billing'
import { exportAsyncFromTable } from '@/utils/excelExport'
import OrderSourceSyncLogDrawer from '../../monitor/orderSource/components/OrderSourceSyncLogDrawer.vue'
import OrderBillProductCheckDrawer from './components/OrderBillProductCheckDrawer.vue'
@@ -284,6 +294,15 @@ function queryList(params) {
return listOrderBill(applySearchRanges(params))
}
function handleExport() {
return exportAsyncFromTable({
baseUrl: '/price/billing/orderBill/export',
tableRef: tableRef.value,
normalize: applySearchRanges,
filePrefix: 'order_bill',
requireFilter: true
})
}
const MAX_REBILL = 10000
-19
View File
@@ -27,7 +27,6 @@
<div class="wizard-actions">
<el-button @click="goBack">取消</el-button>
<el-button v-if="showBasic" type="info" :loading="saving" @click="saveDraft">保存</el-button>
<el-button type="primary" :loading="creating" @click="finishEdit">完成</el-button>
</div>
@@ -67,7 +66,6 @@ const route = useRoute()
const refData = usePriceSetRefData()
const saving = ref(false)
const creating = ref(false)
const progressVisible = ref(false)
const progressPercent = ref(0)
@@ -248,23 +246,6 @@ async function finishEdit() {
}
}
/** Persist basic draft (no detail wipe). */
async function saveDraft() {
const validBasic = await basicRef.value.validate()
if (!validBasic) return
saving.value = true
try {
const priceSetId = await runSaveWithProgress(buildPayload())
form.priceSetId = priceSetId
proxy.$modal.msgSuccess('已保存(待启用),套餐ID=' + priceSetId)
goBack()
} catch (e) {
// surfaced
} finally {
saving.value = false
}
}
function buildPayload(opts = {}) {
const { basic, version } = form
const detailRows = form.details.filter(d => d && d.productCode)