Files
one-pipe-system/src/views/asset-management/asset-information/components/PackageListCard.vue
sexygoat 908367ba5d
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m40s
fix: new
2026-06-15 10:19:35 +08:00

878 lines
28 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>
<ElCard shadow="never" class="info-card package-info">
<template #header>
<div class="card-header">
<span>套餐列表</span>
<div class="card-header-right">
<ElButton
type="primary"
:disabled="Boolean(props.boundDeviceId)"
@click="handleShowRecharge"
>套餐充值</ElButton
>
</div>
</div>
</template>
<div v-loading="loading" class="package-table-wrapper">
<!-- 绑定设备提示 -->
<ElEmpty v-if="props.boundDeviceId" :image-size="100">
<template #description>
<span>该卡已绑定设备请点击 </span>
<ElButton
type="primary"
link
style="font-size: 15px; font-weight: 600"
@click="emit('navigateToDevice', props.boundDeviceNo!)"
>{{ props.boundDeviceNo }}</ElButton
>
<span> 进行查看</span>
</template>
</ElEmpty>
<div v-else-if="packageList && packageList.length > 0" class="table-scroll-container">
<ElTable :data="packageList" class="package-table" border>
<ElTableColumn label="订单号" width="240">
<template #default="scope">
<ElButton
type="primary"
link
@click="handleOrderNoClick(scope.row)"
v-if="hasAuth('order:package_list_detail')"
>
{{ scope.row.order_no }}
</ElButton>
<span v-else>{{ scope.row.order_no }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="套餐名称" width="200" show-overflow-tooltip>
<template #default="scope">
<span class="package-name-text">{{ scope.row.package_name }}</span>
</template>
</ElTableColumn>
<ElTableColumn v-if="isAdminOrPlatform" label="套餐成本价格" width="120">
<template #default="scope">
{{ formatAmount(scope.row.paid_amount || 0) }}
</template>
</ElTableColumn>
<ElTableColumn label="套餐零售价" width="120">
<template #default="scope">
{{ formatAmount(scope.row.retail_amount || 0) }}
</template>
</ElTableColumn>
<ElTableColumn prop="status_name" label="套餐状态" width="100">
<template #default="scope">
<ElTag :type="getPackageStatusTagType(scope.row.status)" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="流量" min-width="450">
<template #default="scope">
<div class="traffic-progress">
<template v-if="isAdminOrPlatform && shouldShowAdminTrafficBreakdown(scope.row)">
<div v-if="canViewCurrentPackageRealUsage" class="progress-text">
<span class="used"
>已使用真流量: {{ formatDataSize(scope.row.real_used_mb || 0) }}</span
>
<span class="total"
>真流量总量: {{ formatDataSize(scope.row.real_total_mb || 0) }}</span
>
<span class="remaining"
>真流量剩余:
{{
formatDataSize(
(scope.row.real_total_mb || 0) - (scope.row.real_used_mb || 0)
)
}}</span
>
</div>
<ElProgress
v-if="canViewCurrentPackageRealUsage"
style="margin-top: 4px"
:percentage="
getUsageProgress(scope.row.real_used_mb || 0, scope.row.real_total_mb || 0)
"
:color="
getProgressColorByPercentage(
getUsageProgress(scope.row.real_used_mb || 0, scope.row.real_total_mb || 0)
)
"
/>
<div
v-if="canShowPackageVirtualUsage(scope.row)"
class="progress-text"
:style="{ marginTop: canViewCurrentPackageRealUsage ? '8px' : '0' }"
>
<span class="used"
>已使用虚流量: {{ formatDataSize(scope.row.virtual_used_mb || 0) }}</span
>
<span class="total"
>停机阈值: {{ formatDataSize(scope.row.virtual_total_mb || 0) }}</span
>
</div>
<div v-if="canShowPackageVirtualUsage(scope.row)" class="progress-wrapper">
<div class="progress-track">
<ElProgress
style="margin-top: 4px"
:show-text="false"
:percentage="
getUsageProgress(
scope.row.virtual_used_mb || 0,
scope.row.real_total_mb || 0
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
scope.row.virtual_used_mb || 0,
scope.row.real_total_mb || 0
)
)
"
/>
<div
class="breakpoint-marker"
:style="
getBreakpointMarkerStyle(
scope.row.virtual_total_mb || 0,
scope.row.real_total_mb || 0
)
"
:title="`停机阈值/真流量总量: ${formatDataSize(scope.row.virtual_total_mb || 0)} / ${formatDataSize(scope.row.real_total_mb || 0)}`"
></div>
</div>
<span class="progress-percentage">
{{
formatProgressPercentage(
getUsageProgress(
scope.row.virtual_used_mb || 0,
scope.row.real_total_mb || 0
)
)
}}
</span>
</div>
</template>
<template v-else-if="!isAdminOrPlatform">
<div class="progress-text">
<span class="used"
>已使用: {{ formatDataSize(getDisplayedUsedMb(scope.row)) }}</span
>
<span class="total"
>总量: {{ formatDataSize(scope.row.real_total_mb || 0) }}</span
>
<span class="remaining"
>剩余: {{ formatDataSize(getDisplayedRemainingMb(scope.row)) }}</span
>
</div>
<ElProgress
style="margin-top: 8px"
:percentage="
getUsageProgress(
getDisplayedUsedMb(scope.row),
getDisplayedTotalMb(scope.row)
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
getDisplayedUsedMb(scope.row),
getDisplayedTotalMb(scope.row)
)
)
"
/>
</template>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="activated_at" label="开始时间" width="170" show-overflow-tooltip>
<template #default="scope">
{{ formatDateTime(scope.row.activated_at) }}
</template>
</ElTableColumn>
<ElTableColumn prop="expires_at" label="到期时间" width="170" show-overflow-tooltip>
<template #default="scope">
{{ formatDateTime(scope.row.expires_at) }}
</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="下单时间" width="170" show-overflow-tooltip>
<template #default="scope">
{{ formatDateTime(scope.row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="240" fixed="right">
<template #default="scope">
<div class="package-actions">
<ElButton
v-if="hasAuth('package_usage:daily_records')"
type="primary"
link
@click="handlePackageNameClick(scope.row)"
>
流量详单
</ElButton>
<ElButton
v-if="scope.row.status !== 4 && hasAuth('package:create_refund')"
type="primary"
link
@click="handleCreateRefund(scope.row)"
>
退款申请
</ElButton>
<ElButton
v-if="
scope.row.status === 4 &&
scope.row.refund_id &&
hasAuth('refund:package_list_detail')
"
type="primary"
link
@click="handleViewRefundDetail(scope.row)"
>
退款详情
</ElButton>
<ElButton
v-if="canUpdateUsedData"
type="primary"
link
@click="handleShowUsedDataDialog(scope.row)"
>
修改已用量
</ElButton>
<ElButton
v-if="canUpdateExpiresAt"
type="primary"
link
@click="handleShowExpiresAtDialog(scope.row)"
>
修改过期时间
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[5, 10, 20]"
layout="total, sizes, prev, pager, next, jumper"
class="pagination"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
<ElEmpty v-else-if="!props.boundDeviceId" description="暂无套餐数据" :image-size="100" />
</div>
<!-- 退款申请对话框 -->
<CreateRefundDialog
v-model="createRefundDialogVisible"
:initial-order-id="currentRefundOrderId"
:initial-order-no="currentRefundOrderNo"
@success="handleRefundSuccess"
/>
<ElDialog
v-model="usedDataDialogVisible"
title="修改已用量"
width="30%"
@closed="resetUsedDataForm"
>
<ElForm
ref="usedDataFormRef"
:model="usedDataForm"
:rules="usedDataRules"
label-width="90px"
>
<ElFormItem label="套餐名称">
<span>{{ selectedPackage?.package_name || '-' }}</span>
</ElFormItem>
<ElFormItem label="当前已用量">
<span>{{ formatDataSize(selectedPackage?.real_used_mb || 0) }}</span>
</ElFormItem>
<ElFormItem label="新的已用量" prop="data_usage">
<div class="used-data-input">
<ElInputNumber
v-model="usedDataForm.data_usage"
:min="0"
:max="usedDataMaxValue"
:precision="usedDataForm.unit === 'MB' ? 0 : 3"
:step="usedDataForm.unit === 'MB' ? 1 : 0.1"
class="used-data-input__number"
placeholder="请输入新的套餐真实已用量"
/>
<ElSelect v-model="usedDataForm.unit" class="used-data-input__unit">
<ElOption label="MB" value="MB" />
<ElOption label="GB" value="GB" />
</ElSelect>
</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="usedDataDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="usedDataSubmitting" @click="handleSubmitUsedData">
确认修改
</ElButton>
</template>
</ElDialog>
<ElDialog
v-model="expiresAtDialogVisible"
title="修改过期时间"
width="420px"
@closed="resetExpiresAtForm"
>
<ElForm
ref="expiresAtFormRef"
:model="expiresAtForm"
:rules="expiresAtRules"
label-width="120px"
>
<ElFormItem label="套餐名称">
<span>{{ selectedPackage?.package_name || '-' }}</span>
</ElFormItem>
<ElFormItem label="当前过期时间">
<span>{{ formatDateTime(selectedPackage?.expires_at) }}</span>
</ElFormItem>
<ElFormItem label="新的过期时间" prop="expires_at">
<ElDatePicker
v-model="expiresAtForm.expires_at"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择新的套餐过期时间"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="expiresAtDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="expiresAtSubmitting" @click="handleSubmitExpiresAt">
确认修改
</ElButton>
</template>
</ElDialog>
</ElCard>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import {
ElCard,
ElTable,
ElTableColumn,
ElTag,
ElButton,
ElProgress,
ElEmpty,
ElPagination,
ElDialog,
ElForm,
ElFormItem,
ElInputNumber,
ElDatePicker,
ElSelect,
ElOption
} from 'element-plus'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
import type { AssetPackageUsageRecord } from '@/types/api'
import { AssetService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { storeToRefs } from 'pinia'
import CreateRefundDialog from '@/components/business/CreateRefundDialog.vue'
const { hasAuth } = useAuth()
const userStore = useUserStore()
const { info: userInfo } = storeToRefs(userStore)
const canViewCurrentPackageRealUsage = computed(() =>
hasAuth('asset_info:view_current_package_real_usage')
)
const canViewCurrentPackageVirtualUsage = computed(() =>
hasAuth('asset_info:view_current_package_virtual_usage')
)
const isAdminOrPlatform = computed(
() => userInfo.value.user_type === 1 || userInfo.value.user_type === 2
)
const isProductionMode = import.meta.env.MODE === 'production'
const canUseAdjustmentInCurrentEnv = (permission: string) => {
if (isProductionMode) {
return userInfo.value.user_type === 2 && hasAuth(permission)
}
return userInfo.value.user_type === 1
}
const canUpdateUsedData = computed(() => canUseAdjustmentInCurrentEnv('package:update_used_data'))
const canUpdateExpiresAt = computed(() =>
canUseAdjustmentInCurrentEnv('package:update_expires_at')
)
// 退款申请对话框
const createRefundDialogVisible = ref(false)
const currentRefundOrderId = ref<number | undefined>(undefined)
const currentRefundOrderNo = ref<string | undefined>(undefined)
const selectedPackage = ref<PackageInfo>()
const usedDataDialogVisible = ref(false)
const expiresAtDialogVisible = ref(false)
const usedDataSubmitting = ref(false)
const expiresAtSubmitting = ref(false)
const usedDataFormRef = ref<FormInstance>()
const expiresAtFormRef = ref<FormInstance>()
const usedDataForm = reactive<{ data_usage: number | undefined; unit: 'MB' | 'GB' }>({
data_usage: undefined,
unit: 'MB'
})
const expiresAtForm = reactive<{ expires_at: string }>({
expires_at: ''
})
const usedDataMaxValue = computed(() => {
const realTotalMb = selectedPackage.value?.real_total_mb ?? 0
return usedDataForm.unit === 'GB' ? Number((realTotalMb / 1024).toFixed(3)) : realTotalMb
})
const validateUsedData = (
_rule: unknown,
_value: number | undefined,
callback: (error?: Error) => void
) => {
const dataUsageMb = getUsedDataMb()
if (usedDataForm.data_usage === undefined || usedDataForm.data_usage === null) {
callback(new Error('请输入新的套餐真实已用量'))
return
}
if (dataUsageMb === undefined || dataUsageMb < 0 || !Number.isInteger(dataUsageMb)) {
callback(new Error('请输入可换算为非负整数 MB 的已用量'))
return
}
if (dataUsageMb > (selectedPackage.value?.real_total_mb ?? 0)) {
callback(new Error('新的已用量不能大于真流量总量'))
return
}
callback()
}
const usedDataRules = reactive<FormRules>({
data_usage: [{ validator: validateUsedData, trigger: 'blur' }]
})
const expiresAtRules = reactive<FormRules>({
expires_at: [{ required: true, message: '请选择新的套餐过期时间', trigger: 'change' }]
})
const handleCreateRefund = (row: PackageInfo) => {
if (!hasAuth('package:create_refund')) {
ElMessage.warning('您没有创建退款申请的权限')
return
}
currentRefundOrderId.value = row.order_id
currentRefundOrderNo.value = row.order_no
createRefundDialogVisible.value = true
}
const handleRefundSuccess = () => {
emit('refresh')
}
const getPackageUsageId = (row: PackageInfo) => row.package_usage_id ?? row.id
const getUsedDataMb = () => {
if (usedDataForm.data_usage === undefined || usedDataForm.data_usage === null) return undefined
return usedDataForm.unit === 'GB' ? usedDataForm.data_usage * 1024 : usedDataForm.data_usage
}
const handleShowUsedDataDialog = (row: PackageInfo) => {
selectedPackage.value = row
usedDataForm.data_usage = row.real_used_mb ?? 0
usedDataForm.unit = 'MB'
usedDataDialogVisible.value = true
}
const handleShowExpiresAtDialog = (row: PackageInfo) => {
selectedPackage.value = row
expiresAtForm.expires_at = row.expires_at ? formatDateTime(row.expires_at) : ''
expiresAtDialogVisible.value = true
}
const resetUsedDataForm = () => {
usedDataFormRef.value?.resetFields()
usedDataForm.data_usage = undefined
usedDataForm.unit = 'MB'
selectedPackage.value = undefined
}
const resetExpiresAtForm = () => {
expiresAtFormRef.value?.resetFields()
expiresAtForm.expires_at = ''
selectedPackage.value = undefined
}
watch(
() => usedDataForm.unit,
() => {
if (usedDataForm.data_usage === undefined) return
usedDataForm.data_usage = Math.min(usedDataForm.data_usage, usedDataMaxValue.value)
}
)
const handleSubmitUsedData = async () => {
if (!usedDataFormRef.value || !selectedPackage.value) return
const packageUsageId = getPackageUsageId(selectedPackage.value)
if (!props.assetIdentifier || packageUsageId === undefined) {
ElMessage.error('缺少资产标识或套餐使用记录ID')
return
}
await usedDataFormRef.value.validate(async (valid) => {
if (!valid) return
usedDataSubmitting.value = true
try {
const dataUsageMb = getUsedDataMb()
await AssetService.updateAssetPackageUsedData(props.assetIdentifier, packageUsageId, {
data_usage_mb: dataUsageMb ?? null
})
ElMessage.success('已用量修改成功')
usedDataDialogVisible.value = false
emit('refresh')
} finally {
usedDataSubmitting.value = false
}
})
}
const handleSubmitExpiresAt = async () => {
if (!expiresAtFormRef.value || !selectedPackage.value) return
const packageUsageId = getPackageUsageId(selectedPackage.value)
if (!props.assetIdentifier || packageUsageId === undefined) {
ElMessage.error('缺少资产标识或套餐使用记录ID')
return
}
await expiresAtFormRef.value.validate(async (valid) => {
if (!valid) return
expiresAtSubmitting.value = true
try {
await AssetService.updateAssetPackageExpiresAt(props.assetIdentifier, packageUsageId, {
expires_at: expiresAtForm.expires_at
})
ElMessage.success('过期时间修改成功')
expiresAtDialogVisible.value = false
emit('refresh')
} finally {
expiresAtSubmitting.value = false
}
})
}
// Props 使用 API 类型
type PackageInfo = AssetPackageUsageRecord
const canShowPackageVirtualUsage = (pkg: PackageInfo) =>
canViewCurrentPackageVirtualUsage.value && pkg.enable_virtual_data !== false
const shouldShowAdminTrafficBreakdown = (pkg: PackageInfo) => {
if (!isAdminOrPlatform.value) return true
return canViewCurrentPackageRealUsage.value || canShowPackageVirtualUsage(pkg)
}
interface Props {
packageList: PackageInfo[]
total?: number
page?: number
pageSize?: number
boundDeviceId?: number
boundDeviceNo?: string
loading?: boolean
assetIdentifier?: string
}
const props = withDefaults(defineProps<Props>(), {
total: 0,
page: 1,
pageSize: 10,
boundDeviceId: undefined,
boundDeviceNo: '',
loading: false,
assetIdentifier: ''
})
// Emits
interface Emits {
(e: 'showDailyRecords', payload: { packageRow: PackageInfo }): void
(e: 'showRecharge'): void
(e: 'page-change', page: number): void
(e: 'size-change', size: number): void
(e: 'navigateToDevice', deviceNo: string): void
(e: 'navigateToOrder', orderId: number): void
(e: 'navigateToRefund', refundId: number): void
(e: 'refresh'): void
}
const emit = defineEmits<Emits>()
// 分页状态
const pagination = ref({
page: props.page,
page_size: props.pageSize,
total: props.total
})
// 监听props变化更新分页
watch(
() => [props.page, props.pageSize, props.total],
([newPage, newPageSize, newTotal]) => {
pagination.value.page = newPage
pagination.value.page_size = newPageSize
pagination.value.total = newTotal
}
)
// 使用格式化工具
const {
formatDataSize,
formatAmount,
getPackageStatusTagType,
getUsageProgress,
getProgressColorByPercentage,
formatProgressPercentage,
getBreakpointMarkerStyle
} = useAssetFormatters()
// 套餐名称点击
const handlePackageNameClick = (packageRow: PackageInfo) => {
if (!hasAuth('package_usage:daily_records')) {
ElMessage.warning('您没有查看流量详单的权限')
return
}
emit('showDailyRecords', { packageRow })
}
// 点击订单号
const handleOrderNoClick = (packageRow: PackageInfo) => {
if (!hasAuth('order:package_list_detail')) {
ElMessage.warning('您没有查看订单详情的权限')
return
}
emit('navigateToOrder', packageRow.order_id!)
}
// 查看退款详情
const handleViewRefundDetail = (packageRow: PackageInfo) => {
if (!hasAuth('refund:detail')) {
ElMessage.warning('您没有查看退款详情的权限')
return
}
emit('navigateToRefund', packageRow.refund_id!)
}
const handleShowRecharge = () => {
emit('showRecharge')
}
// 页码变化
const getDisplayedTotalMb = (pkg: PackageInfo) => pkg.real_total_mb || 0
const getDisplayedUsedMb = (pkg: PackageInfo) =>
pkg.enable_virtual_data ? pkg.virtual_used_mb || 0 : pkg.real_used_mb || 0
const getDisplayedRemainingMb = (pkg: PackageInfo) =>
getDisplayedTotalMb(pkg) - getDisplayedUsedMb(pkg)
const handlePageChange = (page: number) => {
emit('page-change', page)
}
// 每页条数变化
const handleSizeChange = (size: number) => {
emit('size-change', size)
}
</script>
<style scoped lang="scss">
.info-card {
&.package-info {
width: 100%;
overflow: hidden; // 防止内容溢出
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.package-table-wrapper {
.table-scroll-container {
width: 100%;
overflow-x: auto;
}
.package-table {
:deep(.el-table__header) {
th {
font-weight: 600;
color: var(--el-text-color-primary, #374151);
}
}
.package-name-text {
display: block;
overflow: hidden;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px 8px;
align-items: center;
.el-button {
justify-content: flex-start;
width: 100%;
margin-left: 0;
}
}
.traffic-progress {
.progress-text {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
.used {
color: var(--el-color-primary);
}
.remaining {
color: var(--el-color-success);
}
.total {
color: var(--el-text-color-secondary);
}
}
.progress-wrapper {
display: flex;
gap: 1px;
align-items: center;
.progress-track {
position: relative;
flex: 1;
min-width: 0;
.breakpoint-marker {
position: absolute;
top: 50%;
z-index: 1;
width: 8px;
height: 8px;
cursor: pointer;
background-color: var(--el-color-danger);
border-radius: 50%;
transform: translate(-50%, -50%);
}
}
.progress-percentage {
min-width: 44px;
font-size: 14px;
color: var(--el-text-color-secondary);
text-align: right;
white-space: nowrap;
}
}
}
}
}
.pagination {
justify-content: flex-end;
margin-top: 16px;
}
@media (width <= 768px) {
.card-header {
flex-direction: column;
gap: 12px;
align-items: stretch;
.card-header-right,
.card-header-right .el-button {
width: 100%;
}
}
.package-table-wrapper {
.package-table {
min-width: 900px;
}
.traffic-progress {
.progress-text {
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.progress-wrapper {
flex-direction: column;
gap: 6px;
align-items: stretch;
.progress-percentage {
min-width: 0;
text-align: left;
}
}
}
.pagination {
justify-content: flex-start;
overflow-x: auto;
}
}
}
}
}
.used-data-input {
display: flex;
flex-wrap: nowrap;
gap: 8px;
width: 100%;
&__number {
flex: 1;
min-width: 0;
}
&__unit {
width: 112px;
:deep(.el-select__placeholder) {
justify-content: flex-start;
}
:deep(.el-select__selected-item) {
justify-content: flex-start;
}
}
}
</style>