Merge branch 'test' into feauture-20261104

# Conflicts:
#	bms-front/src/views/price/billing/components/BillDetail.vue
This commit is contained in:
liuminghui
2026-11-06 17:30:48 +08:00
52 changed files with 3529 additions and 456 deletions
+9
View File
@@ -8,6 +8,15 @@ export function listPriceLog(query) {
})
}
/** Group by productCode + updateTime for a package version. */
export function listPriceLogGroups(query) {
return request({
url: '/monitor/priceLog/groups',
method: 'get',
params: query
})
}
export function getPriceLog(docId) {
return request({
url: '/monitor/priceLog/' + encodeURIComponent(docId),
+27
View File
@@ -17,3 +17,30 @@ export function getAirBill(billCode) {
params: { billCode }
})
}
/** 一票提单的费用明细 */
export function listAirBillFee(billCode) {
return request({
url: '/price/airBill/fee/list',
method: 'get',
params: { billCode }
})
}
/** 提单费用操作日志,分页参数 pageNum、pageSize */
export function listAirBillFeeLog(query) {
return request({
url: '/price/airBill/fee/log',
method: 'get',
params: query
})
}
/** 只提交新增、修改、删除的费用 */
export function saveAirBillFee(data) {
return request({
url: '/price/airBill/fee/save',
method: 'post',
data
})
}
+9
View File
@@ -187,6 +187,15 @@ export function getBill(billId) {
})
}
/** ES billing history for one bill/order */
export function listBillHistory(billId, query) {
return request({
url: '/price/billing/bill/' + encodeURIComponent(billId) + '/history',
method: 'get',
params: query
})
}
/** Refund receivable bill (billType=REFUND) */
export function refundBill(billId) {
return request({
+22 -8
View File
@@ -123,11 +123,13 @@ function taskDone(task, options) {
* @param {string} [options.filePrefix='export']
* @param {string} [options.loadingText]
* @param {(task: object) => void} [options.onProgress]
* @param {number} [options.pollIntervalMs=800]
* @param {boolean} [options.showLoading=true] full-screen ElLoading; set false when page shows its own float progress
* @param {number} [options.pollIntervalMs=1000]
* @param {number} [options.maxWaitMs]
* @param {string[]} [options.successStatus]
* @param {string[]} [options.failStatus]
* @param {boolean} [options.showSuccessMessage=true]
* @param {boolean} [options.showErrorMessage=true]
*/
export async function exportAsync(options = {}) {
const { submitUrl, taskUrlPrefix, downloadUrlPrefix } = resolveAsyncUrls(options)
@@ -152,11 +154,15 @@ export async function exportAsync(options = {}) {
const filePrefix = options.filePrefix || 'export'
const loadingText = options.loadingText || '\u6b63\u5728\u5bfc\u51fa\uff0c\u8bf7\u7a0d\u5019'
const pollIntervalMs = options.pollIntervalMs || 800
const pollIntervalMs = options.pollIntervalMs || 1000
const maxWaitMs = options.maxWaitMs || 30 * 60 * 1000
const showSuccessMessage = options.showSuccessMessage !== false
const showErrorMessage = options.showErrorMessage !== false
const useLoading = options.showLoading !== false
const loading = ElLoading.service({ text: loadingText, background: 'rgba(0, 0, 0, 0.7)' })
const loading = useLoading
? ElLoading.service({ text: loadingText, background: 'rgba(0, 0, 0, 0.7)' })
: null
try {
const submitRes = await request({
url: submitUrl,
@@ -167,15 +173,23 @@ export async function exportAsync(options = {}) {
})
const taskId = pickTaskId(submitRes && submitRes.data)
if (!taskId) throw new Error('missing export taskId')
if (typeof options.onTaskId === 'function') options.onTaskId(String(taskId))
const started = Date.now()
let task = null
let lastSig = ''
while (Date.now() - started < maxWaitMs) {
const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'
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) {
const st = String(task.status || task.exportStatus || '').toUpperCase()
const sig = [st, pct, task.message, task.rowCount].join('|')
if (typeof options.onProgress === 'function' && sig !== lastSig) {
lastSig = sig
options.onProgress(task)
}
if (loading && !Number.isNaN(pct) && pct >= 0) {
loading.setText(loadingText + ' ' + Math.min(100, pct) + '%')
}
const state = taskDone(task, options)
@@ -183,7 +197,7 @@ export async function exportAsync(options = {}) {
if (state === 'FAIL') {
throw new Error(task.error || task.remark || task.message || 'export failed')
}
await sleep(pollIntervalMs)
await sleep(hidden ? 2500 : pollIntervalMs)
}
if (!task || taskDone(task, options) !== 'SUCCESS') {
throw new Error('export timeout')
@@ -204,10 +218,10 @@ export async function exportAsync(options = {}) {
if (showSuccessMessage) ElMessage.success('\u5bfc\u51fa\u6210\u529f')
return { task, taskId }
} catch (e) {
ElMessage.error((e && e.message) || '\u5bfc\u51fa\u5931\u8d25')
if (showErrorMessage) ElMessage.error((e && e.message) || '\u5bfc\u51fa\u5931\u8d25')
return null
} finally {
loading.close()
if (loading) loading.close()
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ export const BILLING_BILL_EXPORT_FIELDS = [
{ prop: 'customerActiveStatusLabel', label: '客户状态' },
{ prop: 'creditArrearsStatusLabel', label: '额度欠费' },
{ prop: 'bizDate', label: '计费时间' },
{ prop: 'pickupDate', label: '提货时间' },
{ prop: 'pickupDate', label: '收货时间' },
{ prop: 'belongSaler', label: '销售员' },
{ prop: 'settlementMan', label: '结算员' },
{ prop: 'serviceMan', label: '客服员' }
@@ -191,14 +191,8 @@ const orderStatusMap = {
const orderStatusOptions = Object.keys(orderStatusMap).map(k => ({ label: orderStatusMap[k], value: Number(k) }))
const blPickupOptions = [
{ label: '已收货', value: 1 },
{ label: '未收货', value: 0 }
]
const defaultParams = {
orderStatuses: [3, 4, 5, 6, 7, 8, 11],
blPickup: 1
orderStatuses: [3, 4, 5, 6, 7, 8, 11]
}
const searchColumns = [
@@ -248,14 +242,7 @@ const searchColumns = [
collapseTags: true,
collapseTagsTooltip: true
}
},
{
label: '收货状态',
prop: 'blPickup',
component: 'select',
options: blPickupOptions,
placeholder: '请选择'
},
}
]
const columns = [
@@ -402,7 +389,6 @@ function queryList(params) {
const customers = splitLines(src.customerKeywords)
const fillQuery = { orderIdentifiers: identifiers }
if (customers.length) fillQuery.customerKeywords = customers
fillQuery.blPickup = src.blPickup === 0 || src.blPickup === 1 ? src.blPickup : 1
fillQuery.pageNum = params.pageNum
fillQuery.pageSize = params.pageSize
return listOrderSourceByTransferNos(fillQuery)
@@ -435,7 +421,7 @@ function openOrderCompare() {
return
}
proxy.$modal.confirm(
'将按当前客户、提货时间、订单状态和收货状态筛选,异步分页比对上游转单号是否已落库,确认继续?',
'将按当前客户、提货时间和订单状态筛选,异步分页比对上游转单号是否已落库,确认继续?',
'二次确认'
).then(() => {
// 比对只按客户+时间;不带单号框内容(回填后可能有数千转单号,带上会把比对拖死)
@@ -1,265 +0,0 @@
<template>
<div class="app-container">
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
row-key="docId"
>
<template #col-priceKind="{ row }">
<el-tag size="small" :type="kindTag(row)">{{ kindLabel(row) }}</el-tag>
</template>
<template #col-actionName="{ row }">
{{ actionLabel(row) }}
</template>
<template #col-operator="{ row }">
{{ operatorOf(row) }}
</template>
<template #col-productCode="{ row }">
{{ productCodeOf(row) }}
</template>
<template #col-status="{ row }">
<el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="handleView(row)" v-hasPermi="['monitor:priceLog:list']">查看</el-button>
</template>
</basic-table>
<el-drawer v-model="open" size="760px" :title="drawerTitle" append-to-body destroy-on-close>
<div v-loading="loading">
<el-empty v-if="!detail" description="暂无详情" />
<div v-else>
<el-descriptions :column="2" border size="small" class="mb12">
<el-descriptions-item label="价格类型">
<el-tag size="small" :type="kindTag(detail)">{{ kindLabel(detail) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作">{{ actionLabel(detail) }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ operatorOf(detail) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag size="small" :type="statusTag(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="业务ID">{{ detail.bizId || '-' }}</el-descriptions-item>
<el-descriptions-item label="写入时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="产品代码">{{ productCodeOf(detail) }}</el-descriptions-item>
<el-descriptions-item label="国家">{{ countryOf(detail) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.errorMessage" label="错误信息" :span="2">
<span class="err-text">{{ detail.errorMessage }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="change-panel">
<div class="change-panel__head">改前改后</div>
<el-table :data="changesOf(detail)" border size="small" empty-text="无字段变更">
<el-table-column prop="fieldLabel" label="字段" min-width="120" />
<el-table-column prop="oldValue" label="改前" min-width="140" show-overflow-tooltip />
<el-table-column prop="newValue" label="改后" min-width="140" show-overflow-tooltip />
</el-table>
</div>
<div class="json-panel">
<div class="json-panel__head">
<span>请求报文</span>
<el-button size="small" icon="CopyDocument" @click="copyJson(detail.requestJson)">复制</el-button>
</div>
<pre class="code-pre">{{ formatJson(detail.requestJson) }}</pre>
</div>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup name="PriceLog">
import { listPriceLog, getPriceLog } from '@/api/monitor/priceLog'
const tableRef = ref()
const open = ref(false)
const loading = ref(false)
const detail = ref(null)
const drawerTitle = ref('价格日志详情')
const kindOptions = [
{ label: '销售价', value: 'SALES' },
{ label: '产品价', value: 'PRODUCT' },
{ label: '红线价', value: 'REDLINE' }
]
const actionOptions = [
{ label: '新增', value: 'INSERT' },
{ label: '修改', value: 'UPDATE' },
{ label: '删除', value: 'DELETE' },
{ label: '导入', value: 'IMPORT' }
]
const statusOptions = [
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAIL' }
]
const searchColumns = [
{ label: '状态', prop: 'status', component: 'select', options: statusOptions, placeholder: '请选择状态', width: '140px', props: { clearable: true } },
{ label: '业务ID', prop: 'bizId', component: 'input', placeholder: 'SALES_/PRODUCT_/REDLINE_', width: '240px' }
]
const columns = [
{ label: '价格类型', prop: 'priceKind', align: 'center', width: 100, slot: 'priceKind' },
{ label: '操作', prop: 'actionName', align: 'center', width: 90, slot: 'actionName' },
{ label: '操作人', prop: 'operator', width: 110, slot: 'operator', showOverflowTooltip: true },
{ label: '产品代码', prop: 'productCode', minWidth: 120, slot: 'productCode', showOverflowTooltip: true },
{ label: '状态', prop: 'status', align: 'center', width: 90, slot: 'status' },
{ label: '业务ID', prop: 'bizId', minWidth: 160, showOverflowTooltip: true },
{ label: '写入时间', prop: 'updateTime', align: 'center', width: 180 },
{ label: '操作', prop: 'action', align: 'center', width: 90, fixed: 'right', slot: 'action', showInToolbar: false }
]
function queryList(params) {
return listPriceLog(params)
}
function parseReq(row) {
if (!row || !row.requestJson) return {}
try {
return typeof row.requestJson === 'object' ? row.requestJson : JSON.parse(row.requestJson)
} catch (e) {
return {}
}
}
function kindOf(row) {
const req = parseReq(row)
return req.priceKind || ''
}
function kindLabel(row) {
const v = kindOf(row)
const found = kindOptions.find(i => i.value === v)
return found ? found.label : (v || '-')
}
function kindTag(row) {
const v = kindOf(row)
if (v === 'SALES') return 'success'
if (v === 'PRODUCT') return 'warning'
if (v === 'REDLINE') return 'danger'
return 'info'
}
function actionLabel(row) {
const req = parseReq(row)
const found = actionOptions.find(i => i.value === req.action)
return found ? found.label : (req.action || '-')
}
function operatorOf(row) {
return parseReq(row).operator || '-'
}
function productCodeOf(row) {
const keys = parseReq(row).bizKeys || {}
return keys.productCode || '-'
}
function countryOf(row) {
const keys = parseReq(row).bizKeys || {}
if (keys.countryCode && keys.countryName) return keys.countryName + ' (' + keys.countryCode + ')'
return keys.countryCode || keys.countryName || '-'
}
function changesOf(row) {
const req = parseReq(row)
return Array.isArray(req.changes) ? req.changes : []
}
function statusLabel(status) {
const found = statusOptions.find(item => item.value === status)
if (found) return found.label
return status === 'FAIL' ? '失败' : '成功'
}
function statusTag(status) {
return status === 'FAIL' ? 'danger' : 'success'
}
function handleView(row) {
drawerTitle.value = '价格日志 #' + (row.bizId || row.docId)
open.value = true
loading.value = true
detail.value = null
getPriceLog(row.docId).then(res => {
detail.value = res.data || row
}).finally(() => {
loading.value = false
})
}
function formatJson(val) {
if (val === null || val === undefined || val === '') return '无数据'
if (typeof val === 'object') {
try { return JSON.stringify(val, null, 2) } catch (e) { return String(val) }
}
try { return JSON.stringify(JSON.parse(val), null, 2) } catch (e) { return String(val) }
}
function copyJson(val) {
const text = formatJson(val)
const done = () => ElMessage({ message: '已复制', type: 'success', duration: 1500 })
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(done)
return
}
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
done()
}
</script>
<style scoped>
.mb12 { margin-bottom: 12px; }
.err-text { color: #f56c6c; word-break: break-all; }
.change-panel {
margin-bottom: 12px;
border: 1px solid #e8ecf0;
border-radius: 6px;
overflow: hidden;
}
.change-panel__head {
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: #606266;
background: #fafbfc;
border-bottom: 1px solid #ebeef5;
}
.json-panel {
margin-bottom: 12px;
background: #f7f9fb;
border: 1px solid #e8ecf0;
border-radius: 6px;
overflow: hidden;
}
.json-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: #606266;
background: #fafbfc;
border-bottom: 1px solid #ebeef5;
}
.json-panel .code-pre {
margin: 0;
padding: 12px 14px;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -49,18 +49,22 @@
</template>
<template #col-action="{ row }">
<el-button link type="primary" v-hasPermi="['price:airBill:query']" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" v-hasPermi="['price:airBill:fee']" @click="openFee(row)">费用</el-button>
</template>
</basic-table>
<air-bill-detail v-model="open" :title="title" :loading="loading" :detail="detail" />
<air-bill-fee-dialog ref="feeRef" />
</div>
</template>
<script setup name="AirBill">
import { listAirBill, getAirBill } from '@/api/price/airBill'
import AirBillDetail from './components/AirBillDetail.vue'
import AirBillFeeDialog from './components/AirBillFeeDialog.vue'
const tableRef = ref()
const feeRef = ref()
const tableHeight = ref(480)
let tableResizeObserver = null
@@ -102,7 +106,7 @@ const columns = [
{ label: '到达时间', prop: 'arriveTime', width: 170, parseTime: true },
{ label: '到仓时间', prop: 'arriveStockTime', width: 170, parseTime: true },
{ label: '清关时间', prop: 'clearanceTime', width: 170, parseTime: true },
{ label: '操作', prop: 'action', align: 'center', width: 90, fixed: 'right', slot: 'action', showInToolbar: false, showOverflowTooltip: false }
{ label: '操作', prop: 'action', align: 'center', width: 130, fixed: 'right', slot: 'action', showInToolbar: false, showOverflowTooltip: false }
]
const searchColumns = [
@@ -163,6 +167,10 @@ function queryList(params) {
return listAirBill(normalizeQuery(params))
}
function openFee(row) {
feeRef.value?.open(row)
}
function openDetail(row) {
if (!row?.billCode) return
open.value = true
+275 -24
View File
@@ -1,5 +1,20 @@
<template>
<div class="app-container billing-bill">
<div v-if="exportFloatVisible" class="retry-status-float export-status-float">
<el-tooltip :content="exportFloatTooltip" placement="bottom">
<el-button class="retry-status-button" type="warning" plain size="small" @click="openExportProgress()">
<span class="retry-progress-ring" :style="{ '--retry-progress': exportProgressDegree + 'deg' }" aria-hidden="true">
<span class="retry-progress-value">{{ exportProgressText }}</span>
</span>
<span class="retry-status-text">
<strong>{{ exportFloatTitle }}</strong>
<small>{{ exportFloatSummary }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<basic-table
ref="tableRef"
:columns="columns"
@@ -157,6 +172,8 @@
type="warning"
plain
icon="Download"
:loading="exportLoading"
:disabled="exportLoading"
v-hasPermi="['price:billing:export']"
@click="handleExport"
>导出</el-button>
@@ -268,7 +285,33 @@
</div>
</el-dialog>
<excel-export-field-dialog
<el-dialog
v-model="exportProgressVisible"
:title="exportDialogTitle"
width="480px"
append-to-body
:close-on-click-modal="false"
:close-on-press-escape="true"
:show-close="true"
>
<el-progress
:percentage="exportProgressPercent"
:status="exportProgressStatus"
:stroke-width="14"
striped
:striped-flow="exportLoading"
/>
<div class="export-progress__msg">{{ exportProgressMessage }}</div>
<div class="export-progress__text">
导出行数 {{ exportProgressRowCount }}
</div>
<p v-if="exportLoading" class="export-progress__hint">导出在后台进行,可收起弹窗继续操作;请勿关闭或刷新本页</p>
<template #footer>
<el-button v-if="exportLoading" @click="exportProgressVisible = false">收起</el-button>
<el-button v-else type="primary" @click="exportProgressVisible = false">关闭</el-button>
</template>
</el-dialog>
<excel-export-field-dialog
ref="exportFieldRef"
:fields="BILLING_BILL_EXPORT_FIELDS"
storage-key="exportFields:billingBill"
@@ -322,6 +365,78 @@ const productOptions = computed(() =>
.map(p => ({ code: p.productCode, name: p.productName || p.productCode }))
)
const exportLoading = ref(false)
const exportFinished = ref(false)
const exportProgressVisible = ref(false)
const exportProgressPercent = ref(0)
const exportProgressMessage = ref('')
const exportProgressStatus = ref(undefined)
const exportProgressRowCount = ref(0)
const exportTaskId = ref('')
let exportFloatHideTimer = null
const exportFloatVisible = computed(() => exportLoading.value || exportFinished.value)
const exportProgressDegree = computed(() => Math.round(((exportProgressPercent.value || 0) / 100) * 360))
const exportProgressText = computed(() => (exportProgressPercent.value || 0) + '%')
const exportDialogTitle = computed(() => {
if (exportLoading.value) return '异步导出进行中'
if (exportFinished.value) return '异步导出完成'
return '异步导出'
})
const exportFloatTitle = computed(() => {
if (exportLoading.value) return '导出中'
if (exportProgressStatus.value === 'exception') return '导出失败'
if (exportFinished.value) return '导出完成'
return '导出'
})
const exportFloatSummary = computed(() => {
const pct = exportProgressPercent.value || 0
const rows = exportProgressRowCount.value || 0
if (rows > 0) return pct + '% / ' + rows + ' 行'
return pct + '%'
})
const exportFloatTooltip = computed(() => exportProgressMessage.value || '点击查看详情')
function openExportProgress() {
exportProgressVisible.value = true
}
function scheduleHideExportFloat() {
if (exportFloatHideTimer) {
clearTimeout(exportFloatHideTimer)
exportFloatHideTimer = null
}
exportFloatHideTimer = window.setTimeout(() => {
if (!exportLoading.value && exportFinished.value) {
exportFinished.value = false
}
exportFloatHideTimer = null
}, 8000)
}
function applyExportProgress(t) {
const pct = Math.min(100, Number(t && t.percent) || 0)
exportProgressPercent.value = pct
exportProgressRowCount.value = Number(t && t.rowCount) || 0
const st = String((t && t.status) || '').toUpperCase()
if (st === 'PENDING') {
exportProgressMessage.value = (t && t.message) || '排队中...'
exportProgressStatus.value = undefined
} else if (st === 'RUNNING') {
exportProgressMessage.value = (t && t.message) || ('导出中 ' + pct + '%')
exportProgressStatus.value = undefined
} else if (st === 'SUCCESS') {
exportProgressMessage.value = (t && t.message) || '导出完成,正在下载'
exportProgressStatus.value = 'success'
exportProgressPercent.value = 100
} else if (st === 'FAIL' || st === 'FAILED') {
exportProgressMessage.value = (t && (t.error || t.message)) || '导出失败'
exportProgressStatus.value = 'exception'
} else {
exportProgressMessage.value = (t && t.message) || ''
}
}
const tableRef = ref()
const exportFieldRef = ref()
const tableHeight = ref(480)
@@ -460,7 +575,7 @@ const columns = [
{ label: '额度欠费', prop: 'creditArrearsStatus', align: 'center', width: 100, slot: 'creditArrearsStatus' },
// { label: '退费状态', prop: 'refundStatus', align: 'center', width: 100, slot: 'refundStatus' },
{ label: '计费时间', prop: 'bizDate', align: 'center', width: 170, parseTime: true },
{ label: '提货时间', prop: 'pickupDate', align: 'center', width: 170, parseTime: true },
{ label: '收货时间', prop: 'pickupDate', align: 'center', width: 170, parseTime: true },
{ label: '销售员', prop: 'belongSaler', minWidth: 100, showOverflowTooltip: true },
{ label: '结算员', prop: 'settlementMan', minWidth: 100, showOverflowTooltip: true },
{ label: '客服员', prop: 'serviceMan', minWidth: 100, showOverflowTooltip: true },
@@ -534,20 +649,35 @@ const searchColumns = [
// 只选日期时自动补全为当天 00:00:00 ~ 23:59:59
defaultTime: [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
}
},
{
label: '收货时间',
prop: 'pickupDateRange',
component: 'datetimerange',
colSpan: 2,
props: {
clearable: true,
defaultTime: [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
}
}
]
function buildBillQuery(params) {
const { bizDateRange, orderIdentifiers: rawOrderIdentifiers, ...rest } = params || {}
const { bizDateRange, pickupDateRange, orderIdentifiers: rawOrderIdentifiers, ...rest } = params || {}
const query = { ...rest }
const orderIdentifiers = parseOrderIdentifiers(rawOrderIdentifiers)
if (orderIdentifiers.length) query.orderIdentifiers = orderIdentifiers
const rangeParams = {}
if (Array.isArray(bizDateRange) && (bizDateRange[0] || bizDateRange[1])) {
query.params = {
...(query.params || {}),
beginBizDate: bizDateRange[0] || null,
endBizDate: bizDateRange[1] || null
}
rangeParams.beginBizDate = bizDateRange[0] || null
rangeParams.endBizDate = bizDateRange[1] || null
}
if (Array.isArray(pickupDateRange) && (pickupDateRange[0] || pickupDateRange[1])) {
rangeParams.beginPickupDate = pickupDateRange[0] || null
rangeParams.endPickupDate = pickupDateRange[1] || null
}
if (Object.keys(rangeParams).length) {
query.params = { ...(query.params || {}), ...rangeParams }
}
return query
}
@@ -561,21 +691,66 @@ function queryList(params) {
}
async function handleExport() {
if (exportLoading.value) return
const params = tableRef.value?.getQueryParams?.() || {}
if (!validateOrderIdentifierLimit(params.orderIdentifiers)) return
const fields = await exportFieldRef.value?.open?.()
if (!fields || !fields.length) return
return exportAsyncFromTable({
baseUrl: '/price/billing/bill/export',
tableRef: tableRef.value,
normalize: (raw) => {
const q = buildBillQuery(raw)
q.exportFields = fields
return q
},
filePrefix: 'billing_bill',
requireFilter: true
})
if (exportFloatHideTimer) {
clearTimeout(exportFloatHideTimer)
exportFloatHideTimer = null
}
exportLoading.value = true
exportFinished.value = false
exportProgressVisible.value = false
exportProgressPercent.value = 0
exportProgressRowCount.value = 0
exportProgressStatus.value = undefined
exportProgressMessage.value = '导出任务已提交'
exportTaskId.value = ''
try {
const result = await exportAsyncFromTable({
baseUrl: '/price/billing/bill/export',
tableRef: tableRef.value,
normalize: (raw) => {
const q = buildBillQuery(raw)
q.exportFields = fields
return q
},
filePrefix: 'billing_bill',
requireFilter: true,
showLoading: false,
showSuccessMessage: true,
showErrorMessage: true,
onTaskId: (id) => { exportTaskId.value = id },
onProgress: (task) => applyExportProgress(task || {})
})
if (result && result.task) {
applyExportProgress({ ...(result.task || {}), status: 'SUCCESS', percent: 100 })
exportFinished.value = true
scheduleHideExportFloat()
} else if (!exportTaskId.value) {
exportFinished.value = false
exportProgressPercent.value = 0
exportProgressMessage.value = ''
exportProgressStatus.value = undefined
} else if (exportProgressStatus.value !== 'exception') {
exportProgressStatus.value = 'exception'
exportProgressMessage.value = exportProgressMessage.value || '导出失败'
exportFinished.value = true
scheduleHideExportFloat()
} else {
exportFinished.value = true
scheduleHideExportFloat()
}
} catch (e) {
exportProgressStatus.value = 'exception'
exportProgressMessage.value = (e && e.message) || '导出失败'
exportFinished.value = true
scheduleHideExportFloat()
} finally {
exportLoading.value = false
}
}
@@ -666,14 +841,15 @@ function hasBatchFeeQuery(q) {
|| hasArr(q.productCodes)
|| hasArr(q.billingFailStatuses)
|| hasArr(q.bizDateRange)
|| hasArr(q.pickupDateRange)
|| !!(q.creditArrearsStatus && String(q.creditArrearsStatus).trim())
}
function openBatchFee() {
const q = tableRef.value?.getQueryParams?.() || {}
if (!validateOrderIdentifierLimit(q.orderIdentifiers)) return
const { pageNum, pageSize, bizDateRange, ...rest } = q
if (!hasBatchFeeQuery({ ...rest, bizDateRange })) {
const { pageNum, pageSize, bizDateRange, pickupDateRange, ...rest } = q
if (!hasBatchFeeQuery({ ...rest, bizDateRange, pickupDateRange })) {
ElMessage.warning('请先设置列表查询条件')
return
}
@@ -685,6 +861,10 @@ function openBatchFee() {
listQuery.beginBizDate = bizDateRange[0] || null
listQuery.endBizDate = bizDateRange[1] || null
}
if (Array.isArray(pickupDateRange) && (pickupDateRange[0] || pickupDateRange[1])) {
listQuery.beginPickupDate = pickupDateRange[0] || null
listQuery.endPickupDate = pickupDateRange[1] || null
}
batchFeeListQuery.value = listQuery
batchFeeSelectedIds.value = tableRef.value?.getSelectionIds?.() || []
batchFeeOpen.value = true
@@ -697,8 +877,8 @@ function onBatchFeeSuccess() {
function buildRecalcPayload() {
const q = tableRef.value?.getQueryParams?.() || {}
if (!validateOrderIdentifierLimit(q.orderIdentifiers)) return null
const { pageNum, pageSize, bizDateRange, ...rest } = q
if (!hasBatchFeeQuery({ ...rest, bizDateRange })) {
const { pageNum, pageSize, bizDateRange, pickupDateRange, ...rest } = q
if (!hasBatchFeeQuery({ ...rest, bizDateRange, pickupDateRange })) {
ElMessage.warning('请先设置列表查询条件')
return null
}
@@ -720,6 +900,10 @@ function buildRecalcPayload() {
payload.beginBizDate = bizDateRange[0] || null
payload.endBizDate = bizDateRange[1] || null
}
if (Array.isArray(pickupDateRange) && (pickupDateRange[0] || pickupDateRange[1])) {
payload.beginPickupDate = pickupDateRange[0] || null
payload.endPickupDate = pickupDateRange[1] || null
}
return payload
}
@@ -796,7 +980,7 @@ function onChargeWeightUpdated() {
function buildChargeWeightTemplateQuery() {
const q = tableRef.value?.getQueryParams?.() || {}
const { pageNum, pageSize, bizDateRange, ...rest } = q
const { pageNum, pageSize, bizDateRange, pickupDateRange, ...rest } = q
const payload = {
fillByFilter: !!cwFillByFilter.value,
orderNos: rest.orderNos || [],
@@ -812,6 +996,10 @@ function buildChargeWeightTemplateQuery() {
payload.beginBizDate = bizDateRange[0] || null
payload.endBizDate = bizDateRange[1] || null
}
if (Array.isArray(pickupDateRange) && (pickupDateRange[0] || pickupDateRange[1])) {
payload.beginPickupDate = pickupDateRange[0] || null
payload.endPickupDate = pickupDateRange[1] || null
}
return payload
}
@@ -1020,4 +1208,67 @@ function refundTagType(status) {
width: 100%;
}
.order-identifier-search { width: 100%; }
.billing-bill .retry-status-float {
position: fixed;
top: 92px;
right: 24px;
z-index: 3001;
}
.billing-bill .retry-status-button {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 200px;
min-height: 68px;
padding: 6px 16px 6px 7px;
border-radius: 36px;
}
.billing-bill .retry-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-warning) 0deg var(--retry-progress, 45deg),
var(--el-color-warning-light-8) var(--retry-progress, 45deg) 360deg
);
}
.billing-bill .retry-progress-ring::after {
position: absolute;
inset: 5px;
content: '';
border-radius: 50%;
background: var(--el-bg-color);
}
.billing-bill .retry-progress-value {
position: relative;
z-index: 1;
color: var(--el-color-warning);
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.billing-bill .retry-status-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
line-height: 1.2;
white-space: nowrap;
}
.billing-bill .retry-status-text strong {
font-size: 14px;
font-weight: 600;
}
.billing-bill .retry-status-text small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
.export-progress__msg { margin-top: 12px; color: var(--el-text-color-regular); }
.export-progress__text { margin-top: 8px; font-size: 13px; color: var(--el-text-color-secondary); }
.export-progress__hint { margin-top: 12px; font-size: 12px; color: var(--el-color-warning); }
</style>
@@ -0,0 +1,341 @@
<template>
<el-dialog v-model="visible" :title="title" width="980px" append-to-body destroy-on-close @closed="reset">
<div v-loading="loading">
<div class="fee-toolbar">
<el-button type="primary" plain icon="Plus" @click="addRow">新增</el-button>
<span class="fee-hint">只保存本次新增、修改和删除的费用,未改动的行不会重写。</span>
</div>
<el-table :data="rows" border size="small" max-height="320" row-key="rowKey" empty-text="暂无费用,点击新增">
<el-table-column label="费用项" min-width="160">
<template #default="{ row }">
<el-select v-model="row.feeType" filterable clearable placeholder="请选择" style="width: 100%">
<el-option v-for="item in bms_fee_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="价格" width="150">
<template #default="{ row }">
<el-input-number v-model="row.price" :min="0" :precision="4" :controls="false" :value-on-clear="null" placeholder="价格" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="收费币种" width="140">
<template #default="{ row }">
<el-select v-model="row.currency" filterable clearable placeholder="请选择" style="width: 100%">
<el-option v-for="item in bms_currency" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="500" clearable placeholder="备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="log-title">操作日志</div>
<div v-loading="logLoading">
<el-table :data="logs" border size="small" max-height="320" empty-text="暂无操作记录">
<el-table-column label="操作时间" width="168">
<template #default="{ row }">{{ logTime(row) }}</template>
</el-table-column>
<el-table-column label="操作类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="logType(row.operationType)">{{ logAction(row.operationType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="更改前" min-width="200">
<template #default="{ row }">
<div v-if="blankSide(row, 'old')" class="log-empty">空</div>
<div v-else class="log-change">
<div v-for="change in row.changes" :key="change.field">{{ change.fieldLabel || change.field }}:{{ showValue(change.oldValue) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="更改后" min-width="200">
<template #default="{ row }">
<div v-if="blankSide(row, 'new')" class="log-empty">空</div>
<div v-else class="log-change">
<div v-for="change in row.changes" :key="change.field">{{ change.fieldLabel || change.field }}:{{ showValue(change.newValue) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作人" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.createByName || row.createBy || '-' }}</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="logPageNum"
v-model:page-size="logPageSize"
class="log-pager"
:total="logTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="loadLogs"
@size-change="changeLogSize"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!dirty" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { listAirBillFee, listAirBillFeeLog, saveAirBillFee } from '@/api/price/airBill'
import { parseTime } from '@/utils/bms'
const { proxy } = getCurrentInstance()
const { bms_fee_type, bms_currency } = useDict('bms_fee_type', 'bms_currency')
const visible = ref(false)
const loading = ref(false)
const logLoading = ref(false)
const saving = ref(false)
const billCode = ref('')
const rows = ref([])
const deletedIds = ref([])
const logs = ref([])
const logPageNum = ref(1)
const logPageSize = ref(10)
const logTotal = ref(0)
let rowSeed = 0
let logSeq = 0
const title = computed(() => billCode.value ? `提单费用 - ${billCode.value}` : '提单费用')
const dirty = computed(() => deletedIds.value.length > 0 || rows.value.some(row => rowChanged(row)))
function open(row) {
if (!row?.billCode) return
billCode.value = row.billCode
visible.value = true
load()
}
function reset() {
if (visible.value) return
billCode.value = ''
rows.value = []
deletedIds.value = []
logs.value = []
logPageNum.value = 1
logPageSize.value = 10
logTotal.value = 0
logSeq += 1
saving.value = false
loading.value = false
logLoading.value = false
}
function load() {
logPageNum.value = 1
loadFees()
loadLogs()
}
function loadFees() {
const code = billCode.value
loading.value = true
listAirBillFee(code).then(feeRes => {
if (code !== billCode.value) return
deletedIds.value = []
rows.value = (feeRes.data || []).map(item => ({
rowKey: `fee-${item.id}`,
id: item.id,
feeType: item.feeType == null ? '' : String(item.feeType),
price: item.price === null || item.price === undefined ? null : Number(item.price),
currency: item.currency || '',
remark: item.remark || '',
origin: snapshot(item)
}))
}).finally(() => {
if (code === billCode.value) loading.value = false
})
}
function unwrapLogs(res) {
const rows = res?.rows
if (Array.isArray(rows)) {
const total = Number(res.total)
return { list: rows, total: Number.isFinite(total) ? total : rows.length }
}
if (rows && Array.isArray(rows.list)) {
const total = Number(rows.total ?? res.total)
return { list: rows.list, total: Number.isFinite(total) ? total : rows.list.length }
}
if (Array.isArray(res?.data)) {
return { list: res.data, total: res.data.length }
}
return { list: [], total: 0 }
}
function loadLogs() {
const code = billCode.value
if (!code) return
const seq = ++logSeq
logLoading.value = true
listAirBillFeeLog({
billCode: code,
pageNum: logPageNum.value,
pageSize: logPageSize.value
}).then(logRes => {
if (seq !== logSeq || code !== billCode.value) return
const page = unwrapLogs(logRes)
logs.value = page.list
logTotal.value = page.total
}).finally(() => {
if (seq === logSeq && code === billCode.value) logLoading.value = false
})
}
function changeLogSize() {
logPageNum.value = 1
loadLogs()
}
function addRow() {
rowSeed += 1
rows.value.push({
rowKey: `new-${rowSeed}`,
id: null,
feeType: '',
price: null,
currency: '',
remark: '',
origin: null
})
}
function removeRow(row) {
if (row.id != null && !deletedIds.value.some(id => String(id) === String(row.id))) {
deletedIds.value.push(row.id)
}
rows.value = rows.value.filter(item => item.rowKey !== row.rowKey)
}
function snapshot(item) {
return {
feeType: item.feeType || '',
price: normPrice(item.price),
currency: item.currency || '',
remark: (item.remark || '').trim()
}
}
function currentOf(row) {
return {
feeType: row.feeType || '',
price: normPrice(row.price),
currency: row.currency || '',
remark: (row.remark || '').trim()
}
}
function normPrice(value) {
if (value === null || value === undefined || value === '') return ''
const number = Number(value)
return Number.isNaN(number) ? String(value).trim() : String(number)
}
function rowChanged(row) {
if (!row.origin) {
const now = currentOf(row)
return !!(now.feeType || now.price || now.currency || now.remark)
}
const now = currentOf(row)
return now.feeType !== row.origin.feeType || now.price !== row.origin.price
|| now.currency !== row.origin.currency || now.remark !== row.origin.remark
}
function save() {
const inserts = []
const updates = []
rows.value.forEach(row => {
if (!rowChanged(row)) return
const payload = {
feeType: row.feeType || '',
price: row.price,
currency: row.currency || '',
remark: (row.remark || '').trim()
}
if (row.id) updates.push({ id: row.id, ...payload })
else inserts.push(payload)
})
if (!inserts.length && !updates.length && !deletedIds.value.length) {
proxy.$modal.msgError('没有需要保存的变更')
return
}
saving.value = true
saveAirBillFee({ billCode: billCode.value, inserts, updates, deletes: deletedIds.value }).then(res => {
proxy.$modal.msgSuccess(res.msg || '保存成功')
load()
}).catch(() => {
load()
}).finally(() => {
saving.value = false
})
}
function logAction(type) {
return { 1: '新增', 2: '修改', 3: '删除' }[String(type)] || '维护'
}
function logType(type) {
if (String(type) === '1') return 'success'
if (String(type) === '3') return 'danger'
return 'warning'
}
function logTime(item) {
return parseTime(item.createTime) || ''
}
function showValue(value) {
return value === null || value === undefined || value === '' ? '空' : value
}
function blankSide(row, side) {
const changes = row.changes || []
if (!changes.length) return true
const key = side === 'old' ? 'oldValue' : 'newValue'
return changes.every(change => showValue(change[key]) === '空')
}
defineExpose({ open })
</script>
<style scoped>
.fee-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.fee-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.log-title {
margin: 16px 0 8px;
font-weight: 600;
}
.log-change {
line-height: 1.7;
white-space: normal;
}
.log-empty {
color: var(--el-text-color-secondary);
}
.log-pager {
margin-top: 10px;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,265 @@
<template>
<el-drawer
:model-value="modelValue"
:title="title"
size="860px"
append-to-body
destroy-on-close
@update:model-value="$emit('update:modelValue', $event)"
>
<div class="hist-wrap" v-loading="loading">
<el-table :data="rows" border height="calc(100vh - 220px)" :empty-text="emptyText">
<el-table-column :label="colTime" width="170" align="center" prop="updateTime" />
<el-table-column :label="colAction" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="actionTag(row)">{{ actionLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="colStatus" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="colSet" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ priceSetOf(row) }}</template>
</el-table-column>
<el-table-column :label="colVer" width="90" align="center">
<template #default="{ row }">{{ versionOf(row) }}</template>
</el-table-column>
<el-table-column :label="colPsd" width="100" align="center">
<template #default="{ row }">{{ psdOf(row) }}</template>
</el-table-column>
<el-table-column :label="colAmount" width="110" align="right">
<template #default="{ row }">{{ amountOf(row) }}</template>
</el-table-column>
<el-table-column :label="colOps" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="query.pageNum"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="reload"
@current-change="reload"
/>
</div>
</div>
<el-drawer v-model="detailOpen" size="720px" :title="detailTitle" append-to-body destroy-on-close>
<div v-if="!active">
<el-empty :description="emptyDetail" />
</div>
<div v-else>
<el-descriptions :column="2" border size="small" class="mb12">
<el-descriptions-item :label="colTime">{{ active.updateTime || '-' }}</el-descriptions-item>
<el-descriptions-item :label="colAction">{{ actionLabel(active) }}</el-descriptions-item>
<el-descriptions-item :label="colStatus">{{ statusLabel(active.status) }}</el-descriptions-item>
<el-descriptions-item :label="colSet">{{ priceSetOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="colVer">{{ versionOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="colPsd">{{ psdOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="colAmount">{{ amountOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="lbMatch">{{ matchMsgOf(active) }}</el-descriptions-item>
<el-descriptions-item v-if="active.errorMessage" :label="lbError" :span="2">
<span class="err">{{ active.errorMessage }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="json-panel">
<div class="json-panel__head">请求报文</div>
<pre class="code-pre">{{ formatJson(active.requestJson) }}</pre>
</div>
<div class="json-panel">
<div class="json-panel__head">返回报文</div>
<pre class="code-pre">{{ formatJson(active.responseJson) }}</pre>
</div>
</div>
</el-drawer>
</el-drawer>
</template>
<script setup name="BillBillingHistoryDrawer">
import { listBillHistory } from '@/api/price/billing'
const props = defineProps({
modelValue: { type: Boolean, default: false },
billId: { type: String, default: '' },
orderNo: { type: String, default: '' }
})
defineEmits(['update:modelValue'])
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const query = reactive({ pageNum: 1, pageSize: 10 })
const detailOpen = ref(false)
const active = ref(null)
const emptyText = '暂无历史计费记录'
const emptyDetail = '暂无详情'
const colTime = '写入时间'
const colAction = '类型'
const colStatus = '状态'
const colSet = '套餐'
const colVer = '版本'
const colPsd = '明细ID'
const colAmount = '应收总额'
const colOps = '操作'
const lbMatch = '命中说明'
const lbError = '错误信息'
const title = computed(() => {
const no = props.orderNo || ''
return no ? '历史计费记录 · ' + no : '历史计费记录'
})
const detailTitle = computed(() => '计费记录详情')
function parseJson(val) {
if (val == null || val === '') return {}
if (typeof val === 'object') return val
try { return JSON.parse(val) } catch (e) { return {} }
}
function respOf(row) {
return parseJson(row && row.responseJson)
}
function reqOf(row) {
return parseJson(row && row.requestJson)
}
function actionLabel(row) {
const req = reqOf(row)
if (req.action === 'UPSTREAM_SYNC') return '上游同步'
if (row && row.status === 'FAIL') return '计费失败'
const resp = respOf(row)
if (resp.matchSource === 'BILLING_FAIL' || resp.billingFailStatus) return '计费失败'
return '计费'
}
function actionTag(row) {
const label = actionLabel(row)
if (label === '上游同步') return 'info'
if (label === '计费失败') return 'danger'
return 'success'
}
function statusLabel(status) {
return status === 'FAIL' ? '失败' : '成功'
}
function statusTag(status) {
return status === 'FAIL' ? 'danger' : 'success'
}
function priceSetOf(row) {
const r = respOf(row)
if (r.priceSetName && r.priceSetCode) return r.priceSetName + ' (' + r.priceSetCode + ')'
return r.priceSetName || r.priceSetCode || (r.priceSetId != null ? String(r.priceSetId) : '-')
}
function versionOf(row) {
const r = respOf(row)
if (r.versionNo) return r.versionNo
return r.psvId != null ? ('psv=' + r.psvId) : '-'
}
function psdOf(row) {
const r = respOf(row)
return r.psdId != null ? String(r.psdId) : '-'
}
function amountOf(row) {
const r = respOf(row)
const v = r.incomeTotal != null ? r.incomeTotal : r.payableTotal
if (v == null || v === '') return '-'
const n = Number(v)
return Number.isNaN(n) ? String(v) : n.toFixed(2)
}
function matchMsgOf(row) {
return respOf(row).matchMessage || '-'
}
function formatJson(val) {
if (val == null || val === '') return '无数据'
if (typeof val === 'object') {
try { return JSON.stringify(val, null, 2) } catch (e) { return String(val) }
}
try { return JSON.stringify(JSON.parse(val), null, 2) } catch (e) { return String(val) }
}
function openDetail(row) {
active.value = row
detailOpen.value = true
}
async function reload() {
if (!props.billId) {
rows.value = []
total.value = 0
return
}
loading.value = true
try {
const res = await listBillHistory(props.billId, {
pageNum: query.pageNum,
pageSize: query.pageSize
})
rows.value = res.rows || []
total.value = Number(res.total) || 0
} finally {
loading.value = false
}
}
watch(
() => [props.modelValue, props.billId],
([open]) => {
if (open) {
query.pageNum = 1
detailOpen.value = false
active.value = null
reload()
}
}
)
</script>
<style scoped>
.hist-wrap { min-height: 200px; }
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.mb12 { margin-bottom: 12px; }
.err { color: #f56c6c; word-break: break-all; }
.json-panel {
margin-bottom: 12px;
background: #f7f9fb;
border: 1px solid #e8ecf0;
border-radius: 6px;
overflow: hidden;
}
.json-panel__head {
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: #606266;
background: #fafbfc;
border-bottom: 1px solid #ebeef5;
}
.code-pre {
margin: 0;
padding: 12px 14px;
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -41,7 +41,17 @@
</section>
<section class="detail-block">
<div class="sec-title">计费信息</div>
<div class="sec-title">
计费信息
<el-button
v-if="detail && detail.billId"
type="primary"
link
size="small"
class="hist-btn"
@click="historyOpen = true"
>历史计费</el-button>
</div>
<div class="amount-summary">
<div class="amount-card">
<div class="amount-label">运费金额</div>
@@ -187,6 +197,11 @@
:current-charge-weight="detail?.chargeWeight"
@success="onChargeWeightUpdated"
/>
<bill-billing-history-drawer
v-model="historyOpen"
:bill-id="detail?.billId || ''"
:order-no="detail?.orderNo || ''"
/>
</el-drawer>
</template>
@@ -196,6 +211,8 @@ import EditChargeWeightDialog from './EditChargeWeightDialog.vue'
import { deleteIncomeBillFee, getBill } from '@/api/price/billing'
import { generatePayBill } from '@/api/price/pay'
import { ElMessage, ElMessageBox } from 'element-plus'
import BillBillingHistoryDrawer from './BillBillingHistoryDrawer.vue'
import { deleteIncomeBillFee } from '@/api/price/billing'
const { proxy } = getCurrentInstance()
@@ -210,6 +227,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'updated'])
const feeTab = ref('income')
const historyOpen = ref(false)
const addFeeOpen = ref(false)
const editFeeRow = ref(null)
const editCwOpen = ref(false)
@@ -229,6 +247,7 @@ const payFees = computed(() => {
watch(() => props.detail, () => {
feeTab.value = 'income'
historyOpen.value = false
})
function handlePayBill() {
@@ -493,6 +512,9 @@ function refundLabel(status) {
border-radius: 2px;
background: var(--el-color-primary);
}
.hist-btn {
margin-left: auto;
}
.amount-summary {
display: grid;
grid-template-columns: repeat(5, 1fr);
+177 -11
View File
@@ -1,5 +1,19 @@
<template>
<div class="app-container order-bill-query">
<div v-if="exportFloatVisible" class="retry-status-float export-status-float" :class="{ 'is-offset': rebillFloatVisible }">
<el-tooltip :content="exportFloatTooltip" placement="bottom">
<el-button class="retry-status-button" type="warning" plain size="small" @click="openExportProgress()">
<span class="retry-progress-ring" :style="{ '--retry-progress': exportProgressDegree + 'deg' }" aria-hidden="true">
<span class="retry-progress-value">{{ exportProgressText }}</span>
</span>
<span class="retry-status-text">
<strong>{{ exportFloatTitle }}</strong>
<small>{{ exportFloatSummary }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<div v-if="rebillFloatVisible" class="retry-status-float">
<el-tooltip :content="rebillFloatTooltip" placement="bottom">
<el-button class="retry-status-button" type="primary" plain size="small" @click="openRebillProgress()">
@@ -58,6 +72,8 @@
type="warning"
plain
icon="Download"
:loading="exportLoading"
:disabled="exportLoading"
v-hasPermi="['price:orderBill:export']"
@click="handleExport"
>导出</el-button>
@@ -208,6 +224,32 @@
<el-button v-else type="primary" @click="rebillProgressVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
v-model="exportProgressVisible"
:title="exportDialogTitle"
width="480px"
append-to-body
:close-on-click-modal="false"
:close-on-press-escape="true"
:show-close="true"
>
<el-progress
:percentage="exportProgressPercent"
:status="exportProgressStatus"
:stroke-width="14"
striped
:striped-flow="exportLoading"
/>
<div class="rebill-progress__msg">{{ exportProgressMessage }}</div>
<div class="rebill-progress__text">
导出行数 {{ exportProgressRowCount }}
</div>
<p v-if="exportLoading" class="rebill-progress__hint">导出在后台进行,可收起弹窗继续操作;请勿关闭或刷新本页</p>
<template #footer>
<el-button v-if="exportLoading" @click="exportProgressVisible = false">收起</el-button>
<el-button v-else type="primary" @click="exportProgressVisible = false">关闭</el-button>
</template>
</el-dialog>
<order-source-sync-log-drawer ref="logDrawerRef" />
<order-bill-product-check-drawer ref="productCheckDrawerRef" />
@@ -233,6 +275,78 @@ const exportFieldRef = ref()
const logDrawerRef = ref()
const productCheckDrawerRef = ref()
const checkLoading = ref(false)
const exportLoading = ref(false)
const exportFinished = ref(false)
const exportProgressVisible = ref(false)
const exportProgressPercent = ref(0)
const exportProgressMessage = ref('')
const exportProgressStatus = ref(undefined)
const exportProgressRowCount = ref(0)
const exportTaskId = ref('')
let exportFloatHideTimer = null
const exportFloatVisible = computed(() => exportLoading.value || exportFinished.value)
const exportProgressDegree = computed(() => Math.round(((exportProgressPercent.value || 0) / 100) * 360))
const exportProgressText = computed(() => (exportProgressPercent.value || 0) + '%')
const exportDialogTitle = computed(() => {
if (exportLoading.value) return '异步导出进行中'
if (exportFinished.value) return '异步导出完成'
return '异步导出'
})
const exportFloatTitle = computed(() => {
if (exportLoading.value) return '导出中'
if (exportProgressStatus.value === 'exception') return '导出失败'
if (exportFinished.value) return '导出完成'
return '导出'
})
const exportFloatSummary = computed(() => {
const pct = exportProgressPercent.value || 0
const rows = exportProgressRowCount.value || 0
if (rows > 0) return pct + '% / ' + rows + ' 行'
return pct + '%'
})
const exportFloatTooltip = computed(() => exportProgressMessage.value || '点击查看详情')
function openExportProgress() {
exportProgressVisible.value = true
}
function scheduleHideExportFloat() {
if (exportFloatHideTimer) {
clearTimeout(exportFloatHideTimer)
exportFloatHideTimer = null
}
exportFloatHideTimer = window.setTimeout(() => {
if (!exportLoading.value && exportFinished.value) {
exportFinished.value = false
}
exportFloatHideTimer = null
}, 8000)
}
function applyExportProgress(t) {
const pct = Math.min(100, Number(t && t.percent) || 0)
exportProgressPercent.value = pct
exportProgressRowCount.value = Number(t && t.rowCount) || 0
const st = String((t && t.status) || '').toUpperCase()
if (st === 'PENDING') {
exportProgressMessage.value = (t && t.message) || '排队中...'
exportProgressStatus.value = undefined
} else if (st === 'RUNNING') {
exportProgressMessage.value = (t && t.message) || ('导出中 ' + pct + '%')
exportProgressStatus.value = undefined
} else if (st === 'SUCCESS') {
exportProgressMessage.value = (t && t.message) || '导出完成,正在下载'
exportProgressStatus.value = 'success'
exportProgressPercent.value = 100
} else if (st === 'FAIL' || st === 'FAILED') {
exportProgressMessage.value = (t && (t.error || t.message)) || '导出失败'
exportProgressStatus.value = 'exception'
} else {
exportProgressMessage.value = (t && t.message) || ''
}
}
const batchLoading = ref(false)
const rebillFinished = ref(false)
const rebillProgressVisible = ref(false)
@@ -516,21 +630,67 @@ function queryList(params) {
}
async function handleExport() {
if (exportLoading.value) return
const params = tableRef.value?.getQueryParams?.() || {}
if (!validateOrderIdentifierLimit(params.orderIdentifiers)) return
const fields = await exportFieldRef.value?.open?.()
if (!fields || !fields.length) return
return exportAsyncFromTable({
baseUrl: '/price/billing/orderBill/export',
tableRef: tableRef.value,
normalize: (raw) => {
const q = applySearchRanges(raw)
q.exportFields = fields
return q
},
filePrefix: 'order_bill',
requireFilter: true
})
if (exportFloatHideTimer) {
clearTimeout(exportFloatHideTimer)
exportFloatHideTimer = null
}
exportLoading.value = true
exportFinished.value = false
exportProgressVisible.value = false
exportProgressPercent.value = 0
exportProgressRowCount.value = 0
exportProgressStatus.value = undefined
exportProgressMessage.value = '导出任务已提交'
exportTaskId.value = ''
try {
const result = await exportAsyncFromTable({
baseUrl: '/price/billing/orderBill/export',
tableRef: tableRef.value,
normalize: (raw) => {
const q = applySearchRanges(raw)
q.exportFields = fields
return q
},
filePrefix: 'order_bill',
requireFilter: true,
showLoading: false,
showSuccessMessage: true,
showErrorMessage: true,
onTaskId: (id) => { exportTaskId.value = id },
onProgress: (task) => applyExportProgress(task || {})
})
if (result && result.task) {
applyExportProgress({ ...(result.task || {}), status: 'SUCCESS', percent: 100 })
exportFinished.value = true
scheduleHideExportFloat()
} else if (!exportTaskId.value) {
// filter rejected / cancelled before task created
exportFinished.value = false
exportProgressPercent.value = 0
exportProgressMessage.value = ''
exportProgressStatus.value = undefined
} else if (exportProgressStatus.value !== 'exception') {
exportProgressStatus.value = 'exception'
exportProgressMessage.value = exportProgressMessage.value || '导出失败'
exportFinished.value = true
scheduleHideExportFloat()
} else {
exportFinished.value = true
scheduleHideExportFloat()
}
} catch (e) {
exportProgressStatus.value = 'exception'
exportProgressMessage.value = (e && e.message) || '导出失败'
exportFinished.value = true
scheduleHideExportFloat()
} finally {
exportLoading.value = false
}
}
@@ -889,6 +1049,12 @@ onBeforeUnmount(() => {
.rebill-progress__msg { margin-top: 12px; color: var(--el-text-color-regular); }
.rebill-progress__text { margin-top: 8px; font-size: 13px; color: var(--el-text-color-secondary); }
.rebill-progress__hint { margin-top: 12px; font-size: 12px; color: var(--el-color-warning); }
.order-bill-query .export-status-float {
top: 92px;
}
.order-bill-query .export-status-float.is-offset {
top: 178px;
}
.order-bill-query .retry-status-float {
position: fixed;
top: 92px;
@@ -0,0 +1,567 @@
<template>
<el-drawer
:model-value="visible"
:title="title"
size="900px"
append-to-body
destroy-on-close
@close="close"
>
<div class="price-log-drawer" v-loading="loading">
<div class="toolbar">
<el-input
v-model="query.productKeyword"
clearable
:placeholder="phProduct"
style="width: 220px"
@keyup.enter="reload"
/>
<el-button type="primary" @click="reload">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>
<el-empty v-if="!timeTabs.length" :description="emptyText" />
<template v-else>
<el-tabs v-model="activeTime" class="time-tabs" @tab-change="onTimeTabChange">
<el-tab-pane
v-for="t in timeTabs"
:key="t.key"
:name="t.key"
:label="t.label"
/>
</el-tabs>
<el-table :data="productRows" border height="calc(100vh - 320px)" :empty-text="emptyProducts">
<el-table-column :label="colProduct" min-width="120" prop="productCode" show-overflow-tooltip />
<el-table-column :label="colAction" width="90" align="center">
<template #default="{ row }">{{ actionLabelOf(row.action) }}</template>
</el-table-column>
<el-table-column :label="colOperator" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.operator || '-' }}</template>
</el-table-column>
<el-table-column :label="colCount" width="100" align="center" prop="changeCount" />
<el-table-column :label="colOps" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openProduct(row)">明细</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
<el-drawer v-model="groupOpen" size="860px" :title="groupTitle" append-to-body destroy-on-close>
<div v-loading="groupLoading">
<div class="toolbar">
<el-input
v-model="groupQuery.countryKeyword"
clearable
:placeholder="phCountry"
style="width: 220px"
@keyup.enter="searchGroup"
/>
<el-button type="primary" @click="searchGroup">查询</el-button>
<el-button @click="resetGroupQuery">重置</el-button>
</div>
<el-table
:data="displayGroupRows"
border
height="calc(100vh - 260px)"
:empty-text="emptyGroup"
:span-method="groupSpanMethod"
>
<el-table-column :label="colCountry" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ countryOf(row) }}</template>
</el-table-column>
<el-table-column :label="colZone" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ zoneOf(row) }}</template>
</el-table-column>
<el-table-column :label="colWeight" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ weightBandOf(row) }}</template>
</el-table-column>
<el-table-column :label="colAction" width="90" align="center">
<template #default="{ row }">{{ actionLabel(row) }}</template>
</el-table-column>
<el-table-column :label="colChangeSummary" min-width="240" show-overflow-tooltip>
<template #default="{ row }">{{ changeSummary(row) }}</template>
</el-table-column>
<el-table-column :label="colTime" width="170" align="center" prop="updateTime" />
<el-table-column :label="colOps" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="groupQuery.pageNum"
v-model:page-size="groupQuery.pageSize"
:total="groupTotal"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="reloadGroup"
@current-change="reloadGroup"
/>
</div>
</div>
</el-drawer>
<el-drawer v-model="detailOpen" size="720px" :title="detailTitle" append-to-body destroy-on-close>
<div v-loading="detailLoading">
<el-empty v-if="!detail" :description="emptyDetail" />
<div v-else>
<el-descriptions :column="2" border size="small" class="mb12">
<el-descriptions-item :label="lbAction">{{ actionLabel(detail) }}</el-descriptions-item>
<el-descriptions-item :label="lbOperator">{{ operatorOf(detail) }}</el-descriptions-item>
<el-descriptions-item :label="lbStatus">
<el-tag size="small" :type="statusTag(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="lbTime">{{ detail.updateTime || '-' }}</el-descriptions-item>
<el-descriptions-item :label="lbProduct">{{ productCodeOf(detail) }}</el-descriptions-item>
<el-descriptions-item :label="lbCountry">{{ countryOf(detail) }}</el-descriptions-item>
<el-descriptions-item :label="lbWeight">{{ weightBandOf(detail) }}</el-descriptions-item>
<el-descriptions-item :label="lbZone">{{ zoneOf(detail) }}</el-descriptions-item>
</el-descriptions>
<div class="change-panel">
<div class="change-panel__head">改前改后</div>
<el-table :data="changesOf(detail)" border size="small" :empty-text="emptyChanges">
<el-table-column prop="fieldLabel" :label="colField" min-width="120" />
<el-table-column prop="oldValue" :label="colOld" min-width="140" show-overflow-tooltip />
<el-table-column prop="newValue" :label="colNew" min-width="140" show-overflow-tooltip />
</el-table>
</div>
</div>
</div>
</el-drawer>
</el-drawer>
</template>
<script setup name="SetVersionPriceLogDrawer">
import { listPriceLog, listPriceLogGroups, getPriceLog } from '@/api/monitor/priceLog'
const props = defineProps({
modelValue: { type: Boolean, default: false },
version: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
})
const loading = ref(false)
const allGroups = ref([])
const query = reactive({ productKeyword: '' })
const activeTime = ref('')
const groupOpen = ref(false)
const groupLoading = ref(false)
const groupRows = ref([])
const groupTotal = ref(0)
const groupTitle = ref('')
const activeProduct = ref(null)
const groupQuery = reactive({ pageNum: 1, pageSize: 20, countryKeyword: '' })
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const detailTitle = ref('')
const phProduct = '产品代码'
const phCountry = '国家代码/名称'
const emptyText = '该版本暂无价格变更记录'
const emptyProducts = '该时间点暂无产品变更'
const emptyGroup = '该产品暂无明细变更'
const emptyDetail = '暂无详情'
const emptyChanges = '无字段变更'
const colAction = '操作'
const colOperator = '操作人'
const colProduct = '产品代码'
const colCountry = '国家'
const colZone = '分区'
const colWeight = '公斤段'
const colCount = '变更条数'
const colChangeSummary = '变更摘要'
const colStatus = '状态'
const colTime = '写入时间'
const colOps = '操作'
const colField = '字段'
const colOld = '改前'
const colNew = '改后'
const lbAction = '操作'
const lbOperator = '操作人'
const lbStatus = '状态'
const lbTime = '写入时间'
const lbProduct = '产品代码'
const lbCountry = '国家'
const lbWeight = '公斤段'
const lbZone = '分区'
const statusOptions = [
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAIL' }
]
const actionOptions = [
{ label: '新增', value: 'INSERT' },
{ label: '修改', value: 'UPDATE' },
{ label: '删除', value: 'DELETE' },
{ label: '导入', value: 'IMPORT' }
]
const title = computed(() => {
const v = props.version || {}
const ver = v.versionNo || ''
const eff = v.effectiveTime || ''
const base = '版本价格变更记录'
if (ver || eff) return base + ' · ' + (ver || '') + (eff ? ' (' + eff + ')' : '')
return base
})
/** Minute key: yyyy-MM-dd HH:mm */
function toMinuteKey(time) {
if (!time) return '-'
const s = String(time).trim()
return s.length >= 16 ? s.slice(0, 16) : s
}
const timeTabs = computed(() => {
const map = new Map()
for (const g of allGroups.value || []) {
const key = toMinuteKey(g && g.updateTime)
if (!map.has(key)) {
map.set(key, { key, label: key, changeCount: 0, productSet: new Set() })
}
const tab = map.get(key)
tab.changeCount += Number(g.changeCount) || 0
if (g.productCode) tab.productSet.add(g.productCode)
}
return Array.from(map.values())
.sort((a, b) => String(b.key).localeCompare(String(a.key)))
.map(t => ({
key: t.key,
label: t.key + ' (' + t.productSet.size + ')',
changeCount: t.changeCount
}))
})
const productRows = computed(() => {
const minute = activeTime.value
if (!minute) return []
const kw = (query.productKeyword || '').trim().toLowerCase()
const map = new Map()
for (const g of allGroups.value || []) {
if (toMinuteKey(g && g.updateTime) !== minute) continue
const code = g.productCode || '-'
if (kw && !String(code).toLowerCase().includes(kw)) continue
let row = map.get(code)
if (!row) {
row = {
productCode: code,
action: g.action,
operator: g.operator,
status: g.status,
changeCount: 0,
updateTimePrefix: minute
}
map.set(code, row)
}
row.changeCount += Number(g.changeCount) || 0
if (g.status === 'FAIL') row.status = 'FAIL'
if (!row.operator && g.operator) row.operator = g.operator
if (!row.action && g.action) row.action = g.action
}
return Array.from(map.values()).sort((a, b) => String(a.productCode).localeCompare(String(b.productCode)))
})
function close() {
visible.value = false
}
function resetQuery() {
query.productKeyword = ''
reload()
}
function onTimeTabChange() {
groupOpen.value = false
detailOpen.value = false
}
function parseReq(row) {
if (!row || !row.requestJson) return {}
try {
return typeof row.requestJson === 'object' ? row.requestJson : JSON.parse(row.requestJson)
} catch (e) {
return {}
}
}
function actionLabelOf(action) {
const found = actionOptions.find(i => i.value === action)
return found ? found.label : (action || '-')
}
function actionLabel(row) {
return actionLabelOf(parseReq(row).action)
}
function operatorOf(row) {
return parseReq(row).operator || '-'
}
function productCodeOf(row) {
const keys = parseReq(row).bizKeys || {}
return keys.productCode || '-'
}
function countryOf(row) {
const keys = parseReq(row).bizKeys || {}
if (keys.countryCode && keys.countryName) return keys.countryName + ' (' + keys.countryCode + ')'
return keys.countryCode || keys.countryName || '-'
}
function weightBandOf(row) {
const keys = parseReq(row).bizKeys || {}
if (keys.weightLabel) return String(keys.weightLabel)
if (keys.weightMin != null || keys.weightMax != null) {
const min = keys.weightMin != null ? String(keys.weightMin) : ''
const max = keys.weightMax != null ? String(keys.weightMax) : ''
return min + '<W≤' + max
}
return '-'
}
function zoneOf(row) {
const keys = parseReq(row).bizKeys || {}
if (keys.zoneCode && keys.zoneName) return keys.zoneName + ' (' + keys.zoneCode + ')'
return keys.zoneCode || keys.zoneName || '-'
}
function countryKeyOf(row) {
const keys = parseReq(row).bizKeys || {}
return String(keys.countryCode || keys.countryName || '').toUpperCase()
}
function zoneKeyOf(row) {
const keys = parseReq(row).bizKeys || {}
return String(keys.zoneCode || keys.zoneName || '')
}
function weightKeyOf(row) {
const keys = parseReq(row).bizKeys || {}
if (keys.weightLabel) return String(keys.weightLabel)
return String(keys.weightMin ?? '') + '~' + String(keys.weightMax ?? '')
}
const displayGroupRows = computed(() => {
const list = (groupRows.value || []).slice()
list.sort((a, b) => {
const ca = countryKeyOf(a)
const cb = countryKeyOf(b)
if (ca !== cb) return ca.localeCompare(cb)
const za = zoneKeyOf(a)
const zb = zoneKeyOf(b)
if (za !== zb) return za.localeCompare(zb)
return weightKeyOf(a).localeCompare(weightKeyOf(b))
})
return list
})
function buildSpans(rows, keyOf) {
const spans = new Array(rows.length).fill(1)
let i = 0
while (i < rows.length) {
let j = i + 1
const key = keyOf(rows[i])
while (j < rows.length && keyOf(rows[j]) === key) j++
spans[i] = j - i
for (let k = i + 1; k < j; k++) spans[k] = 0
i = j
}
return spans
}
const countrySpans = computed(() => buildSpans(displayGroupRows.value, countryKeyOf))
function spanOf(spans, rowIndex) {
const rowspan = spans[rowIndex] || 0
return rowspan > 0 ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }
}
function groupSpanMethod({ columnIndex, rowIndex }) {
if (columnIndex === 0) return spanOf(countrySpans.value, rowIndex)
return { rowspan: 1, colspan: 1 }
}
function changesOf(row) {
const req = parseReq(row)
return Array.isArray(req.changes) ? req.changes : []
}
function changeSummary(row) {
const list = changesOf(row)
const band = weightBandOf(row)
const pricePart = !list.length
? '-'
: list.map(c => {
const label = (c && c.fieldLabel) || (c && c.field) || ''
const oldV = c && c.oldValue != null ? c.oldValue : ''
const newV = c && c.newValue != null ? c.newValue : ''
return label + ': ' + oldV + ' -> ' + newV
}).join(';')
if (band && band !== '-') {
return '公斤段 ' + band + (pricePart === '-' ? '' : ':' + pricePart)
}
return pricePart
}
function statusLabel(status) {
const found = statusOptions.find(item => item.value === status)
if (found) return found.label
return status === 'FAIL' ? '失败' : '成功'
}
function statusTag(status) {
return status === 'FAIL' ? 'danger' : 'success'
}
async function reload() {
const psvId = props.version && props.version.psvId
if (psvId == null) {
allGroups.value = []
activeTime.value = ''
return
}
loading.value = true
try {
const res = await listPriceLogGroups({
pageNum: 1,
pageSize: 500,
psvId,
priceKind: 'SALES'
})
allGroups.value = res.rows || []
const tabs = timeTabs.value
if (!tabs.length) {
activeTime.value = ''
} else if (!tabs.some(t => t.key === activeTime.value)) {
activeTime.value = tabs[0].key
}
} finally {
loading.value = false
}
}
function openProduct(row) {
if (!row) return
activeProduct.value = row
groupTitle.value = '变更明细 · ' + (row.productCode || '-') + (activeTime.value ? ' @ ' + activeTime.value : '')
groupQuery.pageNum = 1
groupQuery.countryKeyword = ''
groupOpen.value = true
reloadGroup()
}
function searchGroup() {
groupQuery.pageNum = 1
reloadGroup()
}
function resetGroupQuery() {
groupQuery.countryKeyword = ''
groupQuery.pageNum = 1
reloadGroup()
}
async function reloadGroup() {
const psvId = props.version && props.version.psvId
const p = activeProduct.value
if (psvId == null || !p) {
groupRows.value = []
groupTotal.value = 0
return
}
groupLoading.value = true
try {
const kw = (groupQuery.countryKeyword || '').trim()
const res = await listPriceLog({
pageNum: groupQuery.pageNum,
pageSize: groupQuery.pageSize,
psvId,
priceKind: 'SALES',
productCode: p.productCode === '-' ? undefined : p.productCode,
updateTimePrefix: activeTime.value || p.updateTimePrefix || undefined,
countryKeyword: kw || undefined
})
groupRows.value = res.rows || []
groupTotal.value = Number(res.total) || 0
} finally {
groupLoading.value = false
}
}
function handleView(row) {
detailTitle.value = '价格变更 #' + (row.bizId || row.docId)
detailOpen.value = true
detailLoading.value = true
detail.value = null
getPriceLog(row.docId).then(res => {
detail.value = res.data || row
}).finally(() => {
detailLoading.value = false
})
}
watch(
() => [props.modelValue, props.version && props.version.psvId],
([open]) => {
if (open) {
activeTime.value = ''
reload()
} else {
groupOpen.value = false
detailOpen.value = false
detail.value = null
activeProduct.value = null
allGroups.value = []
}
}
)
</script>
<style scoped>
.price-log-drawer { min-height: 240px; }
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.time-tabs {
margin-bottom: 8px;
}
.time-tabs :deep(.el-tabs__header) {
margin-bottom: 8px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.mb12 { margin-bottom: 12px; }
.change-panel {
margin-bottom: 12px;
border: 1px solid #e8ecf0;
border-radius: 6px;
overflow: hidden;
}
.change-panel__head {
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: #606266;
background: #fafbfc;
border-bottom: 1px solid #ebeef5;
}
</style>
@@ -12,13 +12,16 @@
</template>
<el-tabs v-model="statusTab" class="ver-tabs">
<el-tab-pane :label="tabLabel('all')" name="all" />
<el-tab-pane :label="tabLabel('0')" name="0" />
<el-tab-pane :label="tabLabel('3')" name="3" />
<el-tab-pane :label="tabLabel('pending')" name="pending" />
<el-tab-pane :label="tabLabel('1')" name="1" />
<el-tab-pane :label="tabLabel('2')" name="2" />
<el-tab-pane :label="tabLabel('del')" name="del" />
</el-tabs>
<el-table :data="filteredVersions" border empty-text="暂无版本">
<el-table-column :label="colVer" width="100" align="center">
<template #default="{ row }">{{ row.versionNo || '-' }}</template>
</el-table-column>
<el-table-column :label="colEff" min-width="170">
<template #default="{ row }">
<el-button link type="primary" @click="openDetails(row)">{{ row.effectiveTime || '未设置' }}</el-button>
@@ -29,38 +32,47 @@
</el-table-column>
<el-table-column :label="colStatus" width="100" align="center">
<template #default="{ row }">
<el-tag :type="versionStatusTag(row.status)" size="small">{{ versionStatusLabel(row.status) }}</el-tag>
<el-tag :type="isDeletedVersion(row) ? 'danger' : versionStatusTag(row.status)" size="small">{{ statusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="colCreate" min-width="170" align="center">
<template #default="{ row }">{{ row.createTime || '-' }}</template>
</el-table-column>
<el-table-column :label="colRows" width="100" align="center">
<template #default="{ row }">{{ detailCount(row) }}</template>
</el-table-column>
<el-table-column :label="colAction" width="460" align="center" fixed="right">
<el-table-column :label="colAction" width="540" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetails(row)">查看详情</el-button>
<el-button link type="primary" @click="emit('view-import-attach', row)">查看附件</el-button>
<el-button
v-if="showEditButton(row)"
link
type="primary"
v-hasPermi="['monitor:priceLog:list']"
@click="emit('view-price-log', row)"
>变更记录</el-button>
<el-button
v-if="!isDeletedVersion(row) && showEditButton(row)"
link
type="primary"
@click="emit('edit', row)"
>编辑</el-button>
<el-button
v-if="isDraftVersion(row)"
v-if="!isDeletedVersion(row) && isDraftVersion(row)"
link
type="success"
@click="emit('activate', row)"
v-hasPermi="editPerm"
>启动</el-button>
<el-button
v-if="isPendingVersion(row)"
v-if="!isDeletedVersion(row) && isPendingVersion(row)"
link
type="primary"
@click="emit('reschedule', row)"
v-hasPermi="editPerm"
>修改生效时间</el-button>
<el-button
v-if="isDraftVersion(row) || isPendingVersion(row) || isEffectVersion(row)"
v-if="!isDeletedVersion(row) && (isDraftVersion(row) || isPendingVersion(row) || isEffectVersion(row))"
link
type="danger"
:loading="isRemoving(row)"
@@ -89,9 +101,11 @@ const props = defineProps({
modelValue: { type: [Number, String], default: undefined },
removingPsvId: { type: [Number, String], default: null }
})
const emit = defineEmits(['update:modelValue', 'create-version', 'open-details', 'view-import-attach', 'activate', 'reschedule', 'edit', 'remove'])
const emit = defineEmits(['update:modelValue', 'create-version', 'open-details', 'view-import-attach', 'view-price-log', 'activate', 'reschedule', 'edit', 'remove'])
const title = '套餐版本'
const colVer = '版本号'
const colCreate = '创建时间'
const colEff = '生效时间'
const colExp = '失效时间'
const colStatus = '状态'
@@ -99,28 +113,42 @@ const colRows = '明细行数'
const colAction = '操作'
const statusTab = ref('all')
function isDeletedVersion(row) {
return !!row && String(row.isDel) === '1'
}
function statusText(row) {
if (isDeletedVersion(row)) return '已删除'
return versionStatusLabel(row.status)
}
const activeVersions = computed(() => (props.versions || []).filter(v => !isDeletedVersion(v)))
const deletedVersions = computed(() => (props.versions || []).filter(v => isDeletedVersion(v)))
const counts = computed(() => {
const map = { all: 0, '0': 0, '3': 0, '1': 0, '2': 0 }
;(props.versions || []).forEach(v => {
const map = { all: 0, pending: 0, '1': 0, '2': 0, del: 0 }
activeVersions.value.forEach(v => {
map.all++
const s = String(v.status == null ? '' : v.status)
if (map[s] != null) map[s]++
if (s === '0' || s === '3') map.pending++
else if (map[s] != null) map[s]++
})
map.del = deletedVersions.value.length
return map
})
function tabLabel(key) {
const labels = {
all: '全部',
'0': '草稿',
'3': '待生效',
pending: '待生效',
'1': '已生效',
'2': '已失效'
'2': '已失效',
del: '已删除'
}
return labels[key] + ' (' + (counts.value[key] || 0) + ')'
}
const hasDraft = computed(() => (props.versions || []).some(v => isDraftVersion(v)))
const hasDraft = computed(() => activeVersions.value.some(v => isDraftVersion(v)))
function toEffMs(v) {
if (!v || !v.effectiveTime) return NaN
@@ -156,10 +184,21 @@ function sortVersions(list) {
}
const filteredVersions = computed(() => {
const list = props.versions || []
const filtered = statusTab.value === 'all'
? list
: list.filter(v => String(v.status) === statusTab.value)
if (statusTab.value === 'del') {
return sortVersions(deletedVersions.value)
}
const list = activeVersions.value
let filtered
if (statusTab.value === 'all') {
filtered = list
} else if (statusTab.value === 'pending') {
filtered = list.filter(v => {
const s = String(v.status)
return s === '0' || s === '3'
})
} else {
filtered = list.filter(v => String(v.status) === statusTab.value)
}
return sortVersions(filtered)
})
+20 -1
View File
@@ -27,6 +27,7 @@
@create-version="onCreateVersion"
@open-details="onOpenVersionDetails"
@view-import-attach="onViewImportAttach"
@view-price-log="onViewPriceLog"
@activate="onActivateVersion"
@reschedule="onRescheduleVersion"
@edit="onEditVersion"
@@ -39,6 +40,10 @@
:set-type="set.setType"
/>
<set-version-excel-file-drawer ref="versionExcelDrawerRef" />
<set-version-price-log-drawer
v-model="priceLogVisible"
:version="priceLogVersion"
/>
<el-dialog
v-model="activateVisible"
@@ -81,6 +86,7 @@ import SetCustomerTable from './components/detail/SetCustomerTable.vue'
import SetVersionTable from './components/detail/SetVersionTable.vue'
import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.vue'
import SetVersionExcelFileDrawer from './components/detail/SetVersionExcelFileDrawer.vue'
import SetVersionPriceLogDrawer from './components/detail/SetVersionPriceLogDrawer.vue'
import PriceEditPanel from './components/create/PriceEditPanel.vue'
import { usePriceSetRefData } from './composables/usePriceSetRefData'
import { usePriceSetModule } from './composables/usePriceSetModule'
@@ -102,6 +108,8 @@ const detailVersion = ref(null)
const versionExcelDrawerRef = ref()
const editVisible = ref(false)
const editPsvId = ref(undefined)
const priceLogVisible = ref(false)
const priceLogVersion = ref(null)
/** 已加载的套餐ID;页面被 keep-alive 缓存复用时据此判断是否需要重新加载 */
const loadedId = ref(null)
/** 从详情页跳到新建/编辑套餐版本页后,返回时套餐版本数据可能已变化,需强制刷新 */
@@ -115,6 +123,8 @@ function resetTransientState() {
detailVersion.value = null
editVisible.value = false
editPsvId.value = undefined
priceLogVisible.value = false
priceLogVersion.value = null
activateVisible.value = false
activateSubmitting.value = false
activateMode.value = 'activate'
@@ -134,6 +144,13 @@ function onViewImportAttach(row) {
versionExcelDrawerRef.value?.open?.(row)
}
function onViewPriceLog(row) {
if (!row || row.psvId == null) return
priceLogVersion.value = row
priceLogVisible.value = true
}
function load() {
const id = route.query.priceSetId
if (!id) return
@@ -282,6 +299,7 @@ function findPrevVersionEffectiveTime(row) {
let prev = null
for (const v of versions) {
if (!v || v.psvId == null || String(v.psvId) === String(row.psvId)) continue
if (String(v.isDel) === '1') continue
const vs = Number(v.versionSeq) || 0
if (vs >= curSeq || !v.effectiveTime) continue
if (!prev || vs > (Number(prev.versionSeq) || 0)) prev = v
@@ -295,6 +313,7 @@ function collectActivateBoundaryTimes(excludePsvId) {
const seen = new Set()
for (const v of set.value.versions || []) {
if (!v || v.psvId == null || String(v.psvId) === String(excludePsvId)) continue
if (String(v.isDel) === '1') continue
;[v.effectiveTime, v.expireTime].forEach(t => {
const n = t ? normalizeToHour(t) : ''
if (!n || seen.has(n)) return
@@ -432,7 +451,7 @@ function submitActivate() {
function onCreateVersion() {
const priceSetId = set.value.priceSetId
if (priceSetId == null) return
const hasDraft = (set.value.versions || []).some(v => v && String(v.status) === '0')
const hasDraft = (set.value.versions || []).some(v => v && String(v.isDel) !== '1' && String(v.status) === '0')
if (hasDraft) {
proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再新建')
return