1333 lines
42 KiB
Vue
1333 lines
42 KiB
Vue
<template>
|
||
<ArtTableFullScreen>
|
||
<div class="package-list-page" id="table-full-screen">
|
||
<!-- 搜索栏 -->
|
||
<ArtSearchBar
|
||
v-model:filter="searchForm"
|
||
:items="searchFormItems"
|
||
:show-expand="true"
|
||
@reset="handleReset"
|
||
@search="handleSearch"
|
||
></ArtSearchBar>
|
||
|
||
<ElCard shadow="never" class="art-table-card">
|
||
<!-- 表格头部 -->
|
||
<ArtTableHeader
|
||
:columnList="columnOptions"
|
||
v-model:columns="columnChecks"
|
||
@refresh="handleRefresh"
|
||
>
|
||
<template #left>
|
||
<ElButton type="primary" @click="showDialog('add')" v-permission="'package:add'"
|
||
>新增套餐</ElButton
|
||
>
|
||
</template>
|
||
</ArtTableHeader>
|
||
|
||
<!-- 表格 -->
|
||
<ArtTable
|
||
ref="tableRef"
|
||
row-key="id"
|
||
:loading="loading"
|
||
:data="packageList"
|
||
:currentPage="pagination.page"
|
||
:pageSize="pagination.page_size"
|
||
:total="pagination.total"
|
||
:marginTop="10"
|
||
:actions="getActions"
|
||
:inlineActionsCount="2"
|
||
@size-change="handleSizeChange"
|
||
@current-change="handleCurrentChange"
|
||
>
|
||
<template #default>
|
||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||
</template>
|
||
</ArtTable>
|
||
|
||
<!-- 新增/编辑对话框 -->
|
||
<ElDialog
|
||
v-model="dialogVisible"
|
||
:title="dialogType === 'add' ? '新增套餐' : '编辑套餐'"
|
||
width="50%"
|
||
:close-on-click-modal="false"
|
||
@closed="handleDialogClosed"
|
||
>
|
||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="12">
|
||
<ElFormItem label="套餐编码" prop="package_code">
|
||
<ElInput
|
||
v-model="form.package_code"
|
||
placeholder="请输入套餐编码或点击生成"
|
||
:disabled="dialogType === 'edit'"
|
||
clearable
|
||
>
|
||
<template #append>
|
||
<ElButton v-if="dialogType === 'add'" @click="handleGeneratePackageCode"
|
||
>生成编码</ElButton
|
||
>
|
||
</template>
|
||
</ElInput>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
<ElCol :span="12">
|
||
<ElFormItem label="套餐名称" prop="package_name">
|
||
<ElInput v-model="form.package_name" placeholder="请输入套餐名称" clearable />
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="12">
|
||
<ElFormItem label="所属系列" prop="series_id">
|
||
<ElSelect
|
||
v-model="form.series_id"
|
||
placeholder="请选择套餐系列"
|
||
style="width: 100%"
|
||
filterable
|
||
remote
|
||
:remote-method="searchSeries"
|
||
:loading="seriesLoading"
|
||
clearable
|
||
>
|
||
<ElOption
|
||
v-for="series in seriesOptions"
|
||
:key="series.id"
|
||
:label="series.series_name"
|
||
:value="series.id"
|
||
/>
|
||
<template #empty>
|
||
<div style="padding: 10px; color: #909399; text-align: center">
|
||
暂无套餐系列,请先创建套餐系列
|
||
</div>
|
||
</template>
|
||
</ElSelect>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="12">
|
||
<ElFormItem label="套餐类型" prop="package_type">
|
||
<ElSelect
|
||
v-model="form.package_type"
|
||
placeholder="请选择套餐类型"
|
||
style="width: 100%"
|
||
>
|
||
<ElOption
|
||
v-for="option in PACKAGE_TYPE_OPTIONS"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</ElSelect>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
<ElCol :span="12">
|
||
<ElFormItem label="有效期(月)" prop="duration_months">
|
||
<ElInputNumber
|
||
v-model="form.duration_months"
|
||
:min="1"
|
||
:max="120"
|
||
:controls="false"
|
||
style="width: 100%"
|
||
placeholder="请输入有效期(月)"
|
||
/>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<!-- 流量重置周期和套餐周期类型 -->
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="12">
|
||
<ElFormItem label="流量重置周期" prop="data_reset_cycle">
|
||
<ElSelect
|
||
v-model="form.data_reset_cycle"
|
||
placeholder="请选择流量重置周期"
|
||
style="width: 100%"
|
||
clearable
|
||
>
|
||
<ElOption label="每日" value="daily" />
|
||
<ElOption label="每月" value="monthly" />
|
||
<ElOption label="每年" value="yearly" />
|
||
<ElOption label="不重置" value="none" />
|
||
</ElSelect>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
<ElCol :span="12">
|
||
<ElFormItem label="套餐周期类型" prop="calendar_type">
|
||
<ElSelect
|
||
v-model="form.calendar_type"
|
||
placeholder="请选择套餐周期类型(可选)"
|
||
style="width: 100%"
|
||
clearable
|
||
>
|
||
<ElOption label="自然月" value="natural_month" />
|
||
<ElOption label="按天" value="by_day" />
|
||
</ElSelect>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<!-- 套餐天数(calendar_type为by_day时显示) -->
|
||
<ElRow :gutter="20" v-if="form.calendar_type === 'by_day'">
|
||
<ElCol :span="12">
|
||
<ElFormItem label="套餐天数" prop="duration_days">
|
||
<ElInputNumber
|
||
v-model="form.duration_days"
|
||
:min="1"
|
||
:max="3650"
|
||
:controls="false"
|
||
style="width: 100%"
|
||
placeholder="请输入套餐天数"
|
||
/>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<!-- 真流量额度 -->
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="12">
|
||
<ElFormItem label="真流量额度(GB)" prop="real_data_gb">
|
||
<ElInputNumber
|
||
v-model="realDataGb"
|
||
:min="0"
|
||
:precision="2"
|
||
:step="0.1"
|
||
:controls="false"
|
||
style="width: 100%"
|
||
placeholder="请输入真流量额度"
|
||
@change="handleRealDataChange"
|
||
/>
|
||
<div
|
||
v-if="realDataGb"
|
||
style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary)"
|
||
>
|
||
{{ realDataGb }}GB = {{ form.real_data_mb }}MB
|
||
</div>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
<ElCol :span="12">
|
||
<ElFormItem label="启用虚流量">
|
||
<ElSwitch
|
||
v-model="form.enable_virtual_data"
|
||
active-text="启用"
|
||
inactive-text="不启用"
|
||
/>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<!-- 虚流量比例和到期计时基准 -->
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="12" v-if="form.enable_virtual_data">
|
||
<ElFormItem label="虚流量比例(%)" prop="virtual_ratio_percent">
|
||
<ElInputNumber
|
||
v-model="virtualRatioPercent"
|
||
:min="0"
|
||
:max="100"
|
||
:precision="2"
|
||
:step="1"
|
||
:controls="false"
|
||
style="width: 100%"
|
||
placeholder="请输入虚流量比例"
|
||
@change="handleVirtualRatioChange"
|
||
/>
|
||
<div
|
||
v-if="virtualRatioPercent !== null && realDataGb > 0"
|
||
style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary)"
|
||
>
|
||
{{ realDataGb }}×(1-{{ virtualRatioPercent }}%) =
|
||
{{ calculatedVirtualDataGb }}GB
|
||
</div>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
<ElCol :span="12">
|
||
<ElFormItem label="到期计时基准" prop="expiry_base">
|
||
<ElSelect
|
||
v-model="form.expiry_base"
|
||
placeholder="请选择到期计时基准"
|
||
style="width: 100%"
|
||
clearable
|
||
>
|
||
<ElOption label="实名激活时起算" value="from_activation" />
|
||
<ElOption label="购买时起算" value="from_purchase" />
|
||
</ElSelect>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<!-- 成本价和建议零售价 -->
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="12">
|
||
<ElFormItem label="成本价(元)" prop="cost_price">
|
||
<ElInputNumber
|
||
v-model="form.cost_price"
|
||
:min="0"
|
||
:precision="2"
|
||
:step="0.01"
|
||
:controls="false"
|
||
style="width: 100%"
|
||
placeholder="请输入成本价"
|
||
/>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
<ElCol :span="12">
|
||
<ElFormItem label="建议零售价(元)" prop="suggested_retail_price">
|
||
<ElInputNumber
|
||
v-model="form.suggested_retail_price"
|
||
:min="0"
|
||
:precision="2"
|
||
:step="0.01"
|
||
:controls="false"
|
||
style="width: 100%"
|
||
placeholder="请输入建议零售价"
|
||
/>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
|
||
<ElRow :gutter="20">
|
||
<ElCol :span="24">
|
||
<ElFormItem label="套餐描述" prop="description">
|
||
<ElInput
|
||
v-model="form.description"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="请输入套餐描述(可选)"
|
||
maxlength="500"
|
||
show-word-limit
|
||
/>
|
||
</ElFormItem>
|
||
</ElCol>
|
||
</ElRow>
|
||
</ElForm>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||
<ElButton type="primary" @click="handleSubmit(formRef)" :loading="submitLoading">
|
||
提交
|
||
</ElButton>
|
||
</div>
|
||
</template>
|
||
</ElDialog>
|
||
|
||
<!-- 修改零售价对话框 -->
|
||
<ElDialog
|
||
v-model="retailPriceDialogVisible"
|
||
title="修改零售价"
|
||
width="500px"
|
||
:close-on-click-modal="false"
|
||
@closed="handleCloseRetailPriceDialog"
|
||
>
|
||
<ElForm
|
||
ref="retailPriceFormRef"
|
||
:model="retailPriceForm"
|
||
:rules="retailPriceRules"
|
||
label-width="100px"
|
||
>
|
||
<ElFormItem label="套餐名称">
|
||
<span>{{ retailPriceForm.package_name }}</span>
|
||
</ElFormItem>
|
||
<ElFormItem label="零售价" prop="retail_price">
|
||
<ElInputNumber
|
||
v-model="retailPriceForm.retail_price"
|
||
:min="0"
|
||
:max="999999"
|
||
:step="0.01"
|
||
:precision="2"
|
||
placeholder="请输入零售价"
|
||
style="width: 100%"
|
||
/>
|
||
<div style="margin-top: 5px; font-size: 12px; color: #909399">
|
||
单位:元 | 存储值:{{ Math.round(retailPriceForm.retail_price * 100) }} 分
|
||
</div>
|
||
</ElFormItem>
|
||
</ElForm>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<ElButton @click="retailPriceDialogVisible = false">取消</ElButton>
|
||
<ElButton
|
||
type="primary"
|
||
@click="handleConfirmUpdateRetailPrice"
|
||
:loading="retailPriceLoading"
|
||
>
|
||
确认修改
|
||
</ElButton>
|
||
</div>
|
||
</template>
|
||
</ElDialog>
|
||
</ElCard>
|
||
</div>
|
||
</ArtTableFullScreen>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { h, watch } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { PackageManageService, PackageSeriesService } from '@/api/modules'
|
||
import { ElMessage, ElMessageBox, ElTag, ElSwitch, ElInputNumber } from 'element-plus'
|
||
import type { FormInstance, FormRules } from 'element-plus'
|
||
import type { PackageResponse, SeriesSelectOption } from '@/types/api'
|
||
import type { SearchFormItem } from '@/types'
|
||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import { formatDateTime } from '@/utils/business/format'
|
||
import { RoutesAlias } from '@/router/routesAlias'
|
||
import {
|
||
CommonStatus,
|
||
getStatusText,
|
||
frontendStatusToApi,
|
||
apiStatusToFrontend,
|
||
PACKAGE_TYPE_OPTIONS,
|
||
getPackageTypeLabel,
|
||
getPackageTypeTag,
|
||
STATUS_SELECT_OPTIONS,
|
||
SHELF_STATUS_SELECT_OPTIONS,
|
||
getShelfStatusText
|
||
} from '@/config/constants'
|
||
import { generatePackageCode } from '@/utils/codeGenerator'
|
||
|
||
defineOptions({ name: 'PackageList' })
|
||
|
||
const { hasAuth } = useAuth()
|
||
const userStore = useUserStore()
|
||
const router = useRouter()
|
||
|
||
const dialogVisible = ref(false)
|
||
const loading = ref(false)
|
||
const submitLoading = ref(false)
|
||
const seriesLoading = ref(false)
|
||
const tableRef = ref()
|
||
const formRef = ref<FormInstance>()
|
||
const seriesOptions = ref<SeriesSelectOption[]>([])
|
||
const searchSeriesOptions = ref<SeriesSelectOption[]>([])
|
||
|
||
// 零售价修改对话框
|
||
const retailPriceDialogVisible = ref(false)
|
||
const retailPriceLoading = ref(false)
|
||
const retailPriceFormRef = ref<FormInstance>()
|
||
const retailPriceForm = reactive({
|
||
id: 0,
|
||
package_name: '',
|
||
retail_price: 0
|
||
})
|
||
const retailPriceRules = reactive<FormRules>({
|
||
retail_price: [
|
||
{ required: true, message: '请输入零售价', trigger: 'blur' },
|
||
{ type: 'number', message: '零售价必须为数字', trigger: 'blur' }
|
||
]
|
||
})
|
||
|
||
// 使用表格右键菜单功能
|
||
// 搜索表单初始值
|
||
const initialSearchState = {
|
||
package_name: '',
|
||
series_id: undefined as number | undefined,
|
||
package_type: undefined as string | undefined,
|
||
shelf_status: undefined as number | undefined,
|
||
status: undefined as number | undefined
|
||
}
|
||
|
||
// 搜索表单
|
||
const searchForm = reactive({ ...initialSearchState })
|
||
|
||
// 搜索表单配置
|
||
const searchFormItems = computed<SearchFormItem[]>(() => [
|
||
{
|
||
label: '套餐名称',
|
||
prop: 'package_name',
|
||
type: 'input',
|
||
config: {
|
||
clearable: true,
|
||
placeholder: '请输入套餐名称'
|
||
}
|
||
},
|
||
{
|
||
label: '套餐系列',
|
||
prop: 'series_id',
|
||
type: 'select',
|
||
config: {
|
||
clearable: true,
|
||
filterable: true,
|
||
remote: true,
|
||
remoteMethod: handleSearchSeries,
|
||
loading: seriesLoading.value,
|
||
placeholder: '请选择或搜索套餐系列'
|
||
},
|
||
options: () =>
|
||
searchSeriesOptions.value.map((s) => ({
|
||
label: s.series_name,
|
||
value: s.id
|
||
}))
|
||
},
|
||
{
|
||
label: '套餐类型',
|
||
prop: 'package_type',
|
||
type: 'select',
|
||
config: {
|
||
clearable: true,
|
||
placeholder: '请选择套餐类型'
|
||
},
|
||
options: () =>
|
||
PACKAGE_TYPE_OPTIONS.map((o) => ({
|
||
label: o.label,
|
||
value: o.value
|
||
}))
|
||
},
|
||
{
|
||
label: '上架状态',
|
||
prop: 'shelf_status',
|
||
type: 'select',
|
||
config: {
|
||
clearable: true,
|
||
placeholder: '请选择上架状态'
|
||
},
|
||
options: SHELF_STATUS_SELECT_OPTIONS
|
||
},
|
||
{
|
||
label: '状态',
|
||
prop: 'status',
|
||
type: 'select',
|
||
config: {
|
||
clearable: true,
|
||
placeholder: '请选择状态'
|
||
},
|
||
options: STATUS_SELECT_OPTIONS
|
||
}
|
||
])
|
||
|
||
// 分页
|
||
const pagination = reactive({
|
||
page: 1,
|
||
page_size: 20,
|
||
total: 0
|
||
})
|
||
|
||
// 列配置
|
||
const columnOptions = [
|
||
{ label: '套餐编码', prop: 'package_code' },
|
||
{ label: '套餐名称', prop: 'package_name' },
|
||
{ label: '所属系列', prop: 'series_name' },
|
||
{ label: '套餐类型', prop: 'package_type' },
|
||
{ label: '套餐周期类型', prop: 'calendar_type' },
|
||
{ label: '有效期', prop: 'duration_months' },
|
||
{ label: '真流量', prop: 'real_data_mb' },
|
||
{ label: '虚流量', prop: 'virtual_data_mb' },
|
||
{ label: '虚流量比例', prop: 'virtual_ratio' },
|
||
{
|
||
label: '启用虚流量',
|
||
prop: 'enable_virtual_data',
|
||
formatter: (row: any) => (row.enable_virtual_data ? '是' : '否')
|
||
},
|
||
{
|
||
label: '流量重置周期',
|
||
prop: 'data_reset_cycle',
|
||
formatter: (row: any) => {
|
||
const map: Record<string, string> = {
|
||
daily: '每日',
|
||
weekly: '每周',
|
||
monthly: '每月',
|
||
none: '不重置'
|
||
}
|
||
return map[row.data_reset_cycle] || row.data_reset_cycle || '-'
|
||
}
|
||
},
|
||
{
|
||
label: '到期时间基准',
|
||
prop: 'expiry_base',
|
||
formatter: (row: any) => {
|
||
const map: Record<string, string> = {
|
||
from_activation: '实名激活',
|
||
from_purchase: '购买时'
|
||
}
|
||
return map[row.expiry_base] || row.expiry_base || '-'
|
||
}
|
||
},
|
||
{ label: '成本价', prop: 'cost_price' },
|
||
{ label: '建议售价', prop: 'suggested_retail_price' },
|
||
{ label: '上架状态', prop: 'shelf_status' },
|
||
{ label: '状态', prop: 'status' },
|
||
{ label: '创建时间', prop: 'created_at' },
|
||
{ label: '更新时间', prop: 'updated_at' }
|
||
]
|
||
|
||
// 表单验证规则
|
||
const rules = computed<FormRules>(() => {
|
||
const baseRules: FormRules = {
|
||
package_code: [
|
||
{ required: true, message: '请输入套餐编码', trigger: 'blur' },
|
||
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
|
||
],
|
||
package_name: [
|
||
{ required: true, message: '请输入套餐名称', trigger: 'blur' },
|
||
{ min: 1, max: 255, message: '长度在 1 到 255 个字符', trigger: 'blur' }
|
||
],
|
||
series_id: [{ required: true, message: '请选择套餐系列', trigger: 'change' }],
|
||
package_type: [{ required: true, message: '请选择套餐类型', trigger: 'change' }],
|
||
duration_months: [
|
||
{ required: true, message: '请输入有效期', trigger: 'blur' },
|
||
{ type: 'number', min: 1, max: 120, message: '有效期范围 1-120 月', trigger: 'blur' }
|
||
],
|
||
cost_price: [{ required: true, message: '请输入成本价', trigger: 'blur' }]
|
||
}
|
||
|
||
// 如果启用虚流量,则虚流量额度为必填,并且不能超过真流量额度
|
||
if (form.enable_virtual_data) {
|
||
baseRules.virtual_data_mb = [
|
||
{ required: true, message: '请输入虚流量额度', trigger: 'blur' },
|
||
{
|
||
validator: (_rule: any, value: any, callback: any) => {
|
||
if (value && form.real_data_mb && value > form.real_data_mb) {
|
||
callback(new Error('虚流量额度不能超过真流量额度'))
|
||
} else {
|
||
callback()
|
||
}
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
}
|
||
|
||
// 如果流量重置周期是每日,或套餐周期类型是按天,则套餐天数为必填
|
||
if (form.data_reset_cycle === 'daily' || form.calendar_type === 'by_day') {
|
||
baseRules.duration_days = [
|
||
{ required: true, message: '请输入套餐天数', trigger: 'blur' },
|
||
{ type: 'number', min: 1, max: 3650, message: '套餐天数范围 1-3650 天', trigger: 'blur' }
|
||
]
|
||
}
|
||
|
||
return baseRules
|
||
})
|
||
|
||
// 表单数据
|
||
const form = reactive<any>({
|
||
id: 0,
|
||
package_code: '',
|
||
package_name: '',
|
||
series_id: undefined,
|
||
package_type: '',
|
||
calendar_type: undefined,
|
||
duration_days: undefined,
|
||
duration_months: 1,
|
||
data_reset_cycle: undefined,
|
||
enable_virtual_data: false,
|
||
expiry_base: 'from_activation',
|
||
real_data_mb: 0,
|
||
virtual_data_mb: 0,
|
||
cost_price: 0,
|
||
suggested_retail_price: undefined,
|
||
description: ''
|
||
})
|
||
|
||
// GB 输入值(用于界面显示)
|
||
const realDataGb = ref<number>(0)
|
||
const virtualRatioPercent = ref<number | null>(null) // 虚流量比例(百分比)
|
||
|
||
// 计算得出的虚流量GB值
|
||
const calculatedVirtualDataGb = computed(() => {
|
||
if (virtualRatioPercent.value === null || realDataGb.value === 0) {
|
||
return 0
|
||
}
|
||
const ratio = virtualRatioPercent.value / 100
|
||
const virtualGb = realDataGb.value * (1 - ratio)
|
||
return Number(virtualGb.toFixed(2))
|
||
})
|
||
|
||
// GB 转 MB 处理
|
||
const handleRealDataChange = (value: number | null) => {
|
||
if (value === null || value === undefined) {
|
||
form.real_data_mb = 0
|
||
return
|
||
}
|
||
form.real_data_mb = Math.round(value * 1024)
|
||
// 真流量变化时,重新计算虚流量
|
||
handleVirtualRatioChange(virtualRatioPercent.value)
|
||
}
|
||
|
||
// 虚流量比例变化处理
|
||
const handleVirtualRatioChange = (value: number | null) => {
|
||
if (value === null || value === undefined || realDataGb.value === 0) {
|
||
form.virtual_data_mb = 0
|
||
return
|
||
}
|
||
// 根据比例计算虚流量:真流量 × (1 - 比例)
|
||
const virtualGb = calculatedVirtualDataGb.value
|
||
form.virtual_data_mb = Math.round(virtualGb * 1024)
|
||
}
|
||
|
||
const packageList = ref<PackageResponse[]>([])
|
||
const dialogType = ref('add')
|
||
|
||
// 动态列配置
|
||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||
{
|
||
prop: 'package_code',
|
||
label: '套餐编码',
|
||
showOverflowTooltip: true,
|
||
width: 210
|
||
},
|
||
{
|
||
prop: 'package_name',
|
||
label: '套餐名称',
|
||
width: 200,
|
||
showOverflowTooltip: true,
|
||
formatter: (row: PackageResponse) => {
|
||
const hasPermission = hasAuth('package:detail')
|
||
return h(
|
||
'span',
|
||
{
|
||
style: hasPermission
|
||
? 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;'
|
||
: '',
|
||
onClick: hasPermission
|
||
? (e: MouseEvent) => {
|
||
e.stopPropagation()
|
||
handleNameClick(row)
|
||
}
|
||
: undefined
|
||
},
|
||
row.package_name
|
||
)
|
||
}
|
||
},
|
||
{
|
||
prop: 'series_name',
|
||
label: '所属系列',
|
||
width: 160,
|
||
showOverflowTooltip: true
|
||
},
|
||
{
|
||
prop: 'package_type',
|
||
label: '套餐类型',
|
||
width: 100,
|
||
formatter: (row: PackageResponse) => {
|
||
return h(ElTag, { type: getPackageTypeTag(row.package_type), size: 'small' }, () =>
|
||
getPackageTypeLabel(row.package_type)
|
||
)
|
||
}
|
||
},
|
||
{
|
||
prop: 'real_data_mb',
|
||
label: '真流量',
|
||
width: 120,
|
||
formatter: (row: PackageResponse) => {
|
||
const mb = row.real_data_mb ?? 0
|
||
if (mb >= 1024) {
|
||
return `${(mb / 1024).toFixed(2)}GB`
|
||
}
|
||
return `${mb}MB`
|
||
}
|
||
},
|
||
{
|
||
prop: 'virtual_data_mb',
|
||
label: '虚流量',
|
||
width: 120,
|
||
formatter: (row: PackageResponse) => {
|
||
// 如果没有启用虚流量,显示 "-"
|
||
if (!row.enable_virtual_data) {
|
||
return '-'
|
||
}
|
||
const mb = row.virtual_data_mb ?? 0
|
||
if (mb >= 1024) {
|
||
return `${(mb / 1024).toFixed(2)}GB`
|
||
}
|
||
return `${mb}MB`
|
||
}
|
||
},
|
||
{
|
||
prop: 'virtual_ratio',
|
||
label: '虚流量比例',
|
||
width: 120,
|
||
formatter: (row: PackageResponse) => {
|
||
// 如果启用虚流量且真流量大于0,计算虚量百分比
|
||
const virtualData = row.virtual_data_mb ?? 0
|
||
const realData = row.real_data_mb ?? 0
|
||
if (row.enable_virtual_data && realData > 0 && virtualData > 0) {
|
||
// 虚量百分比 = (1 - 虚流量/真流量) * 100%
|
||
// 例如:真流量100G,虚流量70G,则虚量百分比 = (1 - 70/100) * 100% = 30%
|
||
const ratio = (1 - virtualData / realData) * 100
|
||
return `${ratio.toFixed(2)}%`
|
||
}
|
||
// 否则返回 0%
|
||
return '0%'
|
||
}
|
||
},
|
||
{
|
||
prop: 'duration_months',
|
||
label: '有效期',
|
||
width: 100,
|
||
formatter: (row: PackageResponse) => `${row.duration_months}月`
|
||
},
|
||
{
|
||
prop: 'calendar_type',
|
||
label: '套餐周期类型',
|
||
width: 120,
|
||
formatter: (row: PackageResponse) => {
|
||
const map: Record<string, string> = {
|
||
natural_month: '自然月',
|
||
by_day: '按天'
|
||
}
|
||
return map[row.calendar_type] || row.calendar_type || '-'
|
||
}
|
||
},
|
||
{
|
||
prop: 'data_reset_cycle',
|
||
label: '流量重置周期',
|
||
width: 120,
|
||
formatter: (row: PackageResponse) => {
|
||
const map: Record<string, string> = {
|
||
daily: '每日',
|
||
weekly: '每周',
|
||
monthly: '每月',
|
||
none: '不重置'
|
||
}
|
||
return map[row.data_reset_cycle] || row.data_reset_cycle || '-'
|
||
}
|
||
},
|
||
{
|
||
prop: 'expiry_base',
|
||
label: '到期时间基准',
|
||
width: 120,
|
||
formatter: (row: PackageResponse) => {
|
||
const map: Record<string, string> = {
|
||
from_activation: '实名激活',
|
||
from_purchase: '购买时'
|
||
}
|
||
return map[row.expiry_base] || row.expiry_base || '-'
|
||
}
|
||
},
|
||
{
|
||
prop: 'cost_price',
|
||
label: '成本价',
|
||
width: 100,
|
||
formatter: (row: PackageResponse) => `¥${((row.cost_price ?? 0) / 100).toFixed(2)}`
|
||
},
|
||
{
|
||
prop: 'suggested_retail_price',
|
||
label: '建议售价',
|
||
width: 100,
|
||
formatter: (row: PackageResponse) =>
|
||
row.suggested_retail_price
|
||
? `¥${((row.suggested_retail_price ?? 0) / 100).toFixed(2)}`
|
||
: '-'
|
||
},
|
||
{
|
||
prop: 'shelf_status',
|
||
label: '上架状态',
|
||
width: 100,
|
||
formatter: (row: PackageResponse) => {
|
||
return h(ElSwitch, {
|
||
modelValue: row.shelf_status === 1,
|
||
activeText: getShelfStatusText(1),
|
||
inactiveText: getShelfStatusText(2),
|
||
inlinePrompt: true,
|
||
disabled: !hasAuth('package:update_away'),
|
||
'onUpdate:modelValue': (val: string | number | boolean) =>
|
||
handleShelfStatusChange(row, val ? 1 : 2)
|
||
})
|
||
}
|
||
},
|
||
{
|
||
prop: 'status',
|
||
label: '状态',
|
||
width: 100,
|
||
formatter: (row: PackageResponse) => {
|
||
const frontendStatus = apiStatusToFrontend(row.status ?? 0)
|
||
return h(ElSwitch, {
|
||
modelValue: frontendStatus,
|
||
activeValue: CommonStatus.ENABLED,
|
||
inactiveValue: CommonStatus.DISABLED,
|
||
activeText: getStatusText(CommonStatus.ENABLED),
|
||
inactiveText: getStatusText(CommonStatus.DISABLED),
|
||
inlinePrompt: true,
|
||
disabled: !hasAuth('package:update_status'),
|
||
'onUpdate:modelValue': (val: string | number | boolean) =>
|
||
handleStatusChange(row, val as number)
|
||
})
|
||
}
|
||
},
|
||
{
|
||
prop: 'created_at',
|
||
label: '创建时间',
|
||
width: 180,
|
||
formatter: (row: PackageResponse) => formatDateTime(row.created_at)
|
||
}
|
||
])
|
||
|
||
// 操作列配置
|
||
const getActions = (row: PackageResponse) => {
|
||
const actions: any[] = []
|
||
|
||
if (hasAuth('package:edit')) {
|
||
actions.push({ label: '编辑', handler: () => showDialog('edit', row), type: 'primary' })
|
||
}
|
||
|
||
// 只有代理账号(user_type=3)才显示修改零售价
|
||
if (hasAuth('package:update_retail_price') && userStore.getUserInfo.user_type === 3) {
|
||
actions.push({
|
||
label: '修改零售价',
|
||
handler: () => showRetailPriceDialog(row),
|
||
type: 'primary'
|
||
})
|
||
}
|
||
|
||
if (hasAuth('package:delete')) {
|
||
actions.push({ label: '删除', handler: () => deletePackage(row), type: 'danger' })
|
||
}
|
||
|
||
return actions
|
||
}
|
||
|
||
// 监听虚流量开关变化,关闭时重置虚流量额度
|
||
watch(
|
||
() => form.enable_virtual_data,
|
||
(enabled) => {
|
||
if (!enabled) {
|
||
form.virtual_data_mb = 0
|
||
}
|
||
}
|
||
)
|
||
|
||
// 监听真流量额度变化,如果虚流量超过真流量则触发验证
|
||
watch(
|
||
() => form.real_data_mb,
|
||
() => {
|
||
if (form.enable_virtual_data && formRef.value) {
|
||
formRef.value.validateField('virtual_data_mb')
|
||
}
|
||
}
|
||
)
|
||
|
||
// 监听流量重置周期变化
|
||
watch(
|
||
() => form.data_reset_cycle,
|
||
(cycle) => {
|
||
// 流量重置周期变化时不需要清空套餐天数,套餐天数只受calendar_type控制
|
||
}
|
||
)
|
||
|
||
// 监听套餐周期类型变化,不是按天时清空套餐天数
|
||
watch(
|
||
() => form.calendar_type,
|
||
(type) => {
|
||
if (type !== 'by_day') {
|
||
form.duration_days = undefined
|
||
}
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
getTableData()
|
||
})
|
||
|
||
// 监听对话框打开,加载系列选项
|
||
watch(dialogVisible, (visible) => {
|
||
if (visible) {
|
||
loadSeriesOptions()
|
||
}
|
||
})
|
||
|
||
// 加载系列选项(用于新增/编辑对话框,默认加载10条)
|
||
const loadSeriesOptions = async (seriesName?: string) => {
|
||
seriesLoading.value = true
|
||
try {
|
||
const params: any = {
|
||
page: 1,
|
||
page_size: 10,
|
||
status: 1
|
||
}
|
||
if (seriesName) {
|
||
params.series_name = seriesName
|
||
}
|
||
const res = await PackageSeriesService.getPackageSeries(params)
|
||
if (res.code === 0) {
|
||
seriesOptions.value = res.data.items
|
||
}
|
||
} catch (error) {
|
||
console.error('加载系列选项失败:', error)
|
||
} finally {
|
||
seriesLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载搜索栏系列选项(默认加载10条)
|
||
const loadSearchSeriesOptions = async (seriesName?: string) => {
|
||
try {
|
||
const params: any = {
|
||
page: 1,
|
||
page_size: 10,
|
||
status: 1
|
||
}
|
||
if (seriesName) {
|
||
params.series_name = seriesName
|
||
}
|
||
const res = await PackageSeriesService.getPackageSeries(params)
|
||
if (res.code === 0) {
|
||
searchSeriesOptions.value = res.data.items
|
||
}
|
||
} catch (error) {
|
||
console.error('加载搜索栏系列选项失败:', error)
|
||
}
|
||
}
|
||
|
||
// 搜索系列(用于新增/编辑对话框)
|
||
const searchSeries = (query: string) => {
|
||
if (query) {
|
||
loadSeriesOptions(query)
|
||
} else {
|
||
loadSeriesOptions()
|
||
}
|
||
}
|
||
|
||
// 搜索系列(用于搜索栏)
|
||
const handleSearchSeries = (query: string) => {
|
||
if (query) {
|
||
loadSearchSeriesOptions(query)
|
||
} else {
|
||
loadSearchSeriesOptions()
|
||
}
|
||
}
|
||
|
||
// 获取套餐列表
|
||
const getTableData = async () => {
|
||
loading.value = true
|
||
try {
|
||
const params = {
|
||
page: pagination.page,
|
||
page_size: pagination.page_size,
|
||
package_name: searchForm.package_name || undefined,
|
||
series_id: searchForm.series_id || undefined,
|
||
package_type: searchForm.package_type || undefined,
|
||
shelf_status: searchForm.shelf_status || undefined,
|
||
status: searchForm.status || undefined
|
||
}
|
||
const res = await PackageManageService.getPackages(params)
|
||
if (res.code === 0) {
|
||
packageList.value = res.data.items
|
||
pagination.total = res.data.total
|
||
}
|
||
} catch (error) {
|
||
console.error(error)
|
||
console.log('获取套餐列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 重置搜索
|
||
const handleReset = () => {
|
||
Object.assign(searchForm, { ...initialSearchState })
|
||
pagination.page = 1
|
||
getTableData()
|
||
}
|
||
|
||
// 搜索
|
||
const handleSearch = () => {
|
||
pagination.page = 1
|
||
getTableData()
|
||
}
|
||
|
||
// 刷新表格
|
||
const handleRefresh = () => {
|
||
getTableData()
|
||
}
|
||
|
||
// 处理表格分页变化
|
||
const handleSizeChange = (newPageSize: number) => {
|
||
pagination.page_size = newPageSize
|
||
getTableData()
|
||
}
|
||
|
||
const handleCurrentChange = (newCurrentPage: number) => {
|
||
pagination.page = newCurrentPage
|
||
getTableData()
|
||
}
|
||
|
||
// 显示新增/编辑对话框
|
||
const showDialog = async (type: string, row?: PackageResponse) => {
|
||
dialogVisible.value = true
|
||
dialogType.value = type
|
||
|
||
if (type === 'edit' && row) {
|
||
try {
|
||
const res = await PackageManageService.getPackageDetail(row.id)
|
||
if (res.code === 0 && res.data) {
|
||
const data = res.data
|
||
form.id = data.id
|
||
form.package_code = data.package_code
|
||
form.package_name = data.package_name
|
||
form.series_id = data.series_id
|
||
form.package_type = data.package_type
|
||
form.calendar_type = data.calendar_type || undefined
|
||
form.duration_days = data.duration_days
|
||
form.duration_months = data.duration_months
|
||
form.data_reset_cycle = data.data_reset_cycle || undefined
|
||
form.enable_virtual_data = data.enable_virtual_data || false
|
||
form.expiry_base = data.expiry_base || 'from_activation'
|
||
form.real_data_mb = data.real_data_mb || 0
|
||
form.virtual_data_mb = data.virtual_data_mb || 0
|
||
// MB 转换为 GB 显示
|
||
realDataGb.value = form.real_data_mb ? Number((form.real_data_mb / 1024).toFixed(2)) : 0
|
||
// 从虚流量额度反推比例:比例 = (1 - 虚流量/真流量) × 100%
|
||
if (form.real_data_mb > 0 && form.virtual_data_mb > 0) {
|
||
const ratio = (1 - form.virtual_data_mb / form.real_data_mb) * 100
|
||
virtualRatioPercent.value = Number(ratio.toFixed(2))
|
||
} else {
|
||
virtualRatioPercent.value = null
|
||
}
|
||
form.cost_price = (data.cost_price ?? 0) / 100 // 分转换为元显示
|
||
form.suggested_retail_price = data.suggested_retail_price
|
||
? data.suggested_retail_price / 100
|
||
: undefined
|
||
form.description = data.description || ''
|
||
}
|
||
} catch (error) {
|
||
console.error('获取套餐详情失败:', error)
|
||
ElMessage.error('获取套餐详情失败')
|
||
}
|
||
} else {
|
||
form.id = 0
|
||
form.package_code = ''
|
||
form.package_name = ''
|
||
form.series_id = undefined
|
||
form.package_type = ''
|
||
form.calendar_type = undefined
|
||
form.duration_days = undefined
|
||
form.duration_months = 1
|
||
form.data_reset_cycle = undefined
|
||
form.enable_virtual_data = false
|
||
form.expiry_base = 'from_activation'
|
||
form.real_data_mb = 0
|
||
form.virtual_data_mb = 0
|
||
// 重置 GB 值和比例
|
||
realDataGb.value = 0
|
||
virtualRatioPercent.value = null
|
||
form.cost_price = 0
|
||
form.suggested_retail_price = undefined
|
||
form.description = ''
|
||
}
|
||
|
||
// 重置表单验证状态
|
||
nextTick(() => {
|
||
formRef.value?.clearValidate()
|
||
})
|
||
}
|
||
|
||
// 生成套餐编码
|
||
const handleGeneratePackageCode = () => {
|
||
form.package_code = generatePackageCode()
|
||
// 生成编码后清除该字段的验证错误
|
||
nextTick(() => {
|
||
formRef.value?.clearValidate('package_code')
|
||
})
|
||
ElMessage.success('编码生成成功')
|
||
}
|
||
|
||
// 处理弹窗关闭事件
|
||
const handleDialogClosed = () => {
|
||
// 清除表单验证状态
|
||
formRef.value?.clearValidate()
|
||
// 重置表单数据
|
||
form.id = 0
|
||
form.package_code = ''
|
||
form.package_name = ''
|
||
form.series_id = undefined
|
||
form.package_type = ''
|
||
form.calendar_type = undefined
|
||
form.duration_days = undefined
|
||
form.duration_months = 1
|
||
form.data_reset_cycle = undefined
|
||
form.enable_virtual_data = false
|
||
form.expiry_base = 'from_activation'
|
||
form.real_data_mb = 0
|
||
form.virtual_data_mb = 0
|
||
// 重置 GB 值和比例
|
||
realDataGb.value = 0
|
||
virtualRatioPercent.value = null
|
||
form.cost_price = 0
|
||
form.suggested_retail_price = undefined
|
||
form.description = ''
|
||
}
|
||
|
||
// 删除套餐
|
||
const deletePackage = (row: PackageResponse) => {
|
||
ElMessageBox.confirm(`确定删除套餐 ${row.package_name} 吗?`, '删除确认', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'error'
|
||
})
|
||
.then(async () => {
|
||
try {
|
||
await PackageManageService.deletePackage(row.id)
|
||
ElMessage.success('删除成功')
|
||
await getTableData()
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// 用户取消删除
|
||
})
|
||
}
|
||
|
||
// 提交表单
|
||
const handleSubmit = async (formEl: FormInstance | undefined) => {
|
||
if (!formEl) return
|
||
|
||
await formEl.validate(async (valid) => {
|
||
if (valid) {
|
||
submitLoading.value = true
|
||
try {
|
||
// 将元转换为分提交给后端
|
||
const costPriceInCents = Math.round(form.cost_price * 100)
|
||
const suggestedRetailPriceInCents = form.suggested_retail_price
|
||
? Math.round(form.suggested_retail_price * 100)
|
||
: undefined
|
||
|
||
const data: any = {
|
||
package_code: form.package_code,
|
||
package_name: form.package_name,
|
||
package_type: form.package_type,
|
||
duration_months: form.duration_months,
|
||
cost_price: costPriceInCents
|
||
}
|
||
|
||
// 可选字段
|
||
if (form.series_id !== undefined && form.series_id !== null) {
|
||
data.series_id = form.series_id
|
||
}
|
||
if (form.calendar_type) {
|
||
data.calendar_type = form.calendar_type
|
||
}
|
||
if (form.data_reset_cycle) {
|
||
data.data_reset_cycle = form.data_reset_cycle
|
||
}
|
||
// 只有 calendar_type 为 by_day 时才传 duration_days
|
||
if (form.calendar_type === 'by_day' && form.duration_days !== undefined) {
|
||
data.duration_days = form.duration_days
|
||
}
|
||
if (form.enable_virtual_data !== undefined) {
|
||
data.enable_virtual_data = form.enable_virtual_data
|
||
}
|
||
if (form.expiry_base) {
|
||
data.expiry_base = form.expiry_base
|
||
}
|
||
if (suggestedRetailPriceInCents !== undefined && suggestedRetailPriceInCents !== null) {
|
||
data.suggested_retail_price = suggestedRetailPriceInCents
|
||
}
|
||
if (form.real_data_mb !== undefined && form.real_data_mb !== null) {
|
||
data.real_data_mb = form.real_data_mb
|
||
}
|
||
if (form.virtual_data_mb !== undefined && form.virtual_data_mb !== null) {
|
||
data.virtual_data_mb = form.virtual_data_mb
|
||
}
|
||
|
||
if (dialogType.value === 'add') {
|
||
await PackageManageService.createPackage(data)
|
||
ElMessage.success('新增成功')
|
||
} else {
|
||
await PackageManageService.updatePackage(form.id, data)
|
||
ElMessage.success('修改成功')
|
||
}
|
||
|
||
dialogVisible.value = false
|
||
formEl.resetFields()
|
||
await getTableData()
|
||
} catch (error) {
|
||
console.error(error)
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 状态切换
|
||
const handleStatusChange = async (row: PackageResponse, newFrontendStatus: number) => {
|
||
const oldStatus = row.status
|
||
const newApiStatus = frontendStatusToApi(newFrontendStatus)
|
||
row.status = newApiStatus
|
||
try {
|
||
await PackageManageService.updatePackageStatus(row.id, newApiStatus)
|
||
ElMessage.success('状态切换成功')
|
||
} catch (error) {
|
||
row.status = oldStatus
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 上架状态切换
|
||
const handleShelfStatusChange = async (row: PackageResponse, newShelfStatus: number) => {
|
||
const oldShelfStatus = row.shelf_status
|
||
row.shelf_status = newShelfStatus
|
||
try {
|
||
await PackageManageService.updatePackageShelfStatus(row.id, newShelfStatus)
|
||
ElMessage.success('上架状态切换成功')
|
||
} catch (error) {
|
||
row.shelf_status = oldShelfStatus
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 查看详情
|
||
const handleViewDetail = (row: PackageResponse) => {
|
||
router.push(`${RoutesAlias.PackageDetail}/${row.id}`)
|
||
}
|
||
|
||
// 处理名称点击
|
||
const handleNameClick = (row: PackageResponse) => {
|
||
if (hasAuth('package:detail')) {
|
||
handleViewDetail(row)
|
||
} else {
|
||
ElMessage.warning('您没有查看详情的权限')
|
||
}
|
||
}
|
||
|
||
// 显示零售价修改对话框
|
||
const showRetailPriceDialog = (row: PackageResponse) => {
|
||
retailPriceForm.id = row.id
|
||
retailPriceForm.package_name = row.package_name
|
||
// 将分转换为元显示
|
||
retailPriceForm.retail_price = row.suggested_retail_price ? row.suggested_retail_price / 100 : 0
|
||
retailPriceDialogVisible.value = true
|
||
}
|
||
|
||
// 确认修改零售价
|
||
const handleConfirmUpdateRetailPrice = () => {
|
||
retailPriceFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
|
||
retailPriceLoading.value = true
|
||
try {
|
||
// 将元转换为分
|
||
const priceInCents = Math.round(retailPriceForm.retail_price * 100)
|
||
const res = await PackageManageService.updateRetailPrice(retailPriceForm.id, priceInCents)
|
||
if (res.code === 0) {
|
||
ElMessage.success('零售价修改成功')
|
||
retailPriceDialogVisible.value = false
|
||
await getTableData()
|
||
}
|
||
} catch (error: any) {
|
||
console.error('修改零售价失败:', error)
|
||
} finally {
|
||
retailPriceLoading.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
// 关闭零售价对话框
|
||
const handleCloseRetailPriceDialog = () => {
|
||
retailPriceDialogVisible.value = false
|
||
retailPriceFormRef.value?.resetFields()
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.dialog-footer {
|
||
text-align: right;
|
||
}
|
||
</style>
|