Files
one-pipe-system/src/views/package-management/package-list/index.vue
sexygoat 4470a4ef04
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m47s
修改: bug
2026-02-27 17:40:02 +08:00

949 lines
28 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"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增套餐' : '编辑套餐'"
width="600px"
:close-on-click-modal="false"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="150px">
<ElFormItem label="套餐编码" prop="package_code">
<div style="display: flex; gap: 8px">
<ElInput
v-model="form.package_code"
placeholder="请输入套餐编码或点击生成"
:disabled="dialogType === 'edit'"
clearable
style="flex: 1"
/>
<ElButton v-if="dialogType === 'add'" @click="handleGeneratePackageCode">
生成编码
</ElButton>
</div>
</ElFormItem>
<ElFormItem label="套餐名称" prop="package_name">
<ElInput v-model="form.package_name" placeholder="请输入套餐名称" clearable />
</ElFormItem>
<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"
/>
</ElSelect>
</ElFormItem>
<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>
<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>
<ElFormItem label="有效期(月)" prop="duration_months">
<ElInputNumber
v-model="form.duration_months"
:min="1"
:max="120"
:controls="false"
style="width: 100%"
placeholder="请输入有效期(月)"
/>
</ElFormItem>
<ElFormItem
v-if="form.calendar_type === 'by_day'"
label="套餐天数"
prop="duration_days"
>
<ElInputNumber
v-model="form.duration_days"
:min="1"
:max="3650"
:controls="false"
style="width: 100%"
placeholder="请输入套餐天数"
/>
</ElFormItem>
<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>
<ElFormItem label="真流量额度(MB)" prop="real_data_mb">
<ElInputNumber
v-model="form.real_data_mb"
:min="0"
:controls="false"
style="width: 100%"
placeholder="请输入真流量额度"
/>
</ElFormItem>
<ElFormItem label="启用虚流量">
<ElSwitch
v-model="form.enable_virtual_data"
active-text="启用"
inactive-text="不启用"
/>
</ElFormItem>
<ElFormItem
label="虚流量额度(MB)"
prop="virtual_data_mb"
v-if="form.enable_virtual_data"
>
<ElInputNumber
v-model="form.virtual_data_mb"
:min="0"
:controls="false"
style="width: 100%"
placeholder="请输入虚流量额度"
/>
</ElFormItem>
<ElFormItem label="启用实名激活">
<ElSwitch
v-model="form.enable_realname_activation"
active-text="启用"
inactive-text="不启用"
/>
</ElFormItem>
<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>
<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>
<ElFormItem label="套餐描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入套餐描述(可选)"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)" :loading="submitLoading">
提交
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { PackageManageService, PackageSeriesService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag, ElSwitch, ElButton } 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 ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import {
CommonStatus,
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
PACKAGE_TYPE_OPTIONS,
getPackageTypeLabel,
getPackageTypeTag
} from '@/config/constants'
import { generatePackageCode } from '@/utils/codeGenerator'
defineOptions({ name: 'PackageList' })
const { hasAuth } = useAuth()
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 contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentRow = ref<PackageResponse | null>(null)
const seriesOptions = ref<SeriesSelectOption[]>([])
const searchSeriesOptions = ref<SeriesSelectOption[]>([])
// 搜索表单初始值
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: () => [
{ label: '上架', value: 1 },
{ label: '下架', value: 2 }
]
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '请选择状态'
},
options: () => [
{ label: '启用', value: 1 },
{ label: '禁用', value: 2 }
]
}
])
// 分页
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: 'real_data_mb' },
{ label: '虚流量', prop: 'virtual_data_mb' },
{ label: '有效期', prop: 'duration_months' },
{ label: '成本价', prop: 'cost_price' },
{ label: '建议售价', prop: 'suggested_retail_price' },
{ label: '上架状态', prop: 'shelf_status' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'created_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' }
],
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' }]
}
// 如果套餐周期类型是按天,则套餐天数为必填
if (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,
enable_realname_activation: false,
real_data_mb: 0,
virtual_data_mb: 0,
cost_price: 0,
suggested_retail_price: undefined,
description: ''
})
const packageList = ref<PackageResponse[]>([])
const dialogType = ref('add')
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'package_code',
label: '套餐编码',
showOverflowTooltip: true,
width: 210
},
{
prop: 'package_name',
label: '套餐名称',
minWidth: 160,
showOverflowTooltip: true
},
{
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: 100,
formatter: (row: PackageResponse) => `${row.real_data_mb}MB`
},
{
prop: 'virtual_data_mb',
label: '虚流量',
width: 100,
formatter: (row: PackageResponse) => `${row.virtual_data_mb}MB`
},
{
prop: 'duration_months',
label: '有效期',
width: 100,
formatter: (row: PackageResponse) => `${row.duration_months}`
},
{
prop: 'cost_price',
label: '成本价',
width: 100,
formatter: (row: PackageResponse) => `¥${(row.cost_price / 100).toFixed(2)}`
},
{
prop: 'suggested_retail_price',
label: '建议售价',
width: 100,
formatter: (row: PackageResponse) =>
row.suggested_retail_price ? `¥${(row.suggested_retail_price / 100).toFixed(2)}` : '-'
},
{
prop: 'shelf_status',
label: '上架状态',
width: 100,
formatter: (row: PackageResponse) => {
return h(ElSwitch, {
modelValue: row.shelf_status === 1,
activeText: '上架',
inactiveText: '下架',
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)
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 contextMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
items.push({
key: 'detail',
label: '详情'
})
if (hasAuth('package:edit')) {
items.push({
key: 'edit',
label: '编辑'
})
}
if (hasAuth('package:delete')) {
items.push({
key: 'delete',
label: '删除'
})
}
return items
})
// 监听虚流量开关变化,关闭时重置虚流量额度
watch(
() => form.enable_virtual_data,
(enabled) => {
if (!enabled) {
form.virtual_data_mb = 0
}
}
)
onMounted(() => {
loadSeriesOptions()
loadSearchSeriesOptions()
getTableData()
})
// 加载系列选项(用于新增/编辑对话框,默认加载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)
ElMessage.error('获取套餐列表失败')
} 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 = (type: string, row?: PackageResponse) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
form.id = row.id
form.package_code = row.package_code
form.package_name = row.package_name
form.series_id = row.series_id
form.package_type = row.package_type
form.calendar_type = row.calendar_type || undefined
form.duration_days = row.duration_days || undefined
form.duration_months = row.duration_months
form.data_reset_cycle = row.data_reset_cycle || undefined
form.enable_virtual_data = row.enable_virtual_data || false
form.enable_realname_activation = row.enable_realname_activation || false
form.real_data_mb = row.real_data_mb || 0
form.virtual_data_mb = row.virtual_data_mb || 0
form.cost_price = row.cost_price / 100 // 分转换为元显示
form.suggested_retail_price = row.suggested_retail_price
? row.suggested_retail_price / 100
: undefined
form.description = row.description || ''
} 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.enable_realname_activation = false
form.real_data_mb = 0
form.virtual_data_mb = 0
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.enable_realname_activation = false
form.real_data_mb = 0
form.virtual_data_mb = 0
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,
enable_virtual_data: form.enable_virtual_data || false,
enable_realname_activation: form.enable_realname_activation || false
}
// 可选字段
if (form.series_id) {
data.series_id = form.series_id
}
if (form.calendar_type) {
data.calendar_type = form.calendar_type
}
if (form.calendar_type === 'by_day' && form.duration_days) {
data.duration_days = form.duration_days
}
if (form.data_reset_cycle) {
data.data_reset_cycle = form.data_reset_cycle
}
if (suggestedRetailPriceInCents !== undefined) {
data.suggested_retail_price = suggestedRetailPriceInCents
}
if (form.real_data_mb) {
data.real_data_mb = form.real_data_mb
}
if (form.enable_virtual_data && form.virtual_data_mb) {
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 handleRowContextMenu = (row: PackageResponse, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
case 'detail':
handleViewDetail(currentRow.value)
break
case 'edit':
showDialog('edit', currentRow.value)
break
case 'delete':
deletePackage(currentRow.value)
break
}
}
</script>
<style lang="scss" scoped>
.package-list-page {
// 可以添加特定样式
}
.dialog-footer {
text-align: right;
}
</style>