fix: 回调配置, 调整信用位置, 套餐
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m55s

This commit is contained in:
luo
2026-07-29 18:20:42 +08:00
parent 4394ae0f78
commit 17d2eeebc5
39 changed files with 2687 additions and 1532 deletions

View File

@@ -727,10 +727,10 @@
package_name: '',
series_id: undefined,
package_type: '',
calendar_type: undefined,
calendar_type: null,
duration_days: undefined,
duration_months: 1,
data_reset_cycle: undefined,
data_reset_cycle: null,
enable_virtual_data: false,
expiry_base: 'from_activation',
real_data_mb: 0,
@@ -1345,10 +1345,10 @@
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.calendar_type = data.calendar_type ?? null
form.duration_days = data.duration_days
form.duration_months = data.duration_months
form.data_reset_cycle = data.data_reset_cycle || undefined
form.data_reset_cycle = data.data_reset_cycle ?? null
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
@@ -1384,10 +1384,10 @@
form.package_name = ''
form.series_id = undefined
form.package_type = ''
form.calendar_type = undefined
form.calendar_type = null
form.duration_days = undefined
form.duration_months = 1
form.data_reset_cycle = undefined
form.data_reset_cycle = null
form.enable_virtual_data = false
form.expiry_base = 'from_activation'
form.real_data_mb = 0
@@ -1428,10 +1428,10 @@
form.package_name = ''
form.series_id = undefined
form.package_type = ''
form.calendar_type = undefined
form.calendar_type = null
form.duration_days = undefined
form.duration_months = 1
form.data_reset_cycle = undefined
form.data_reset_cycle = null
form.enable_virtual_data = false
form.expiry_base = 'from_activation'
form.real_data_mb = 0
@@ -1483,6 +1483,8 @@
package_type: form.package_type,
duration_months: form.duration_months,
cost_price: costPriceInCents,
calendar_type: form.calendar_type ?? null,
data_reset_cycle: form.data_reset_cycle ?? null,
is_gift: form.is_gift
}
@@ -1494,12 +1496,6 @@
if (form.series_id !== undefined) {
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

View File

@@ -143,45 +143,23 @@
<!-- 套餐列表 -->
<div v-if="detailData.packages && detailData.packages.length > 0" class="package-table">
<ElTable :data="detailData.packages" border stripe>
<ElTableColumn prop="package_name" label="套餐名称" />
<ElTableColumn prop="package_code" label="套餐编码" />
<ElTableColumn label="成本价">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="套餐默认生效条件">
<template #default="{ row }">
{{ row.default_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="覆盖生效条件">
<template #default="{ row }">
{{ row.expiry_base_override_name || '跟随套餐默认' }}
</template>
</ElTableColumn>
<ElTableColumn label="最终生效条件">
<template #default="{ row }">
{{ row.effective_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<SeriesGrantPackageTable
:data="detailData.packages"
:loading="loading"
:show-actions="true"
:actions-width="240"
>
<template #actions="{ row }">
<div class="package-table-actions">
<ElButton
v-if="canUpdateExpiryBase"
type="primary"
size="small"
link
@click="showExpiryBaseDialog(row)"
>
修改生效条件
</ElButton>
<ElButton
type="primary"
size="small"
@@ -200,9 +178,9 @@
>
删除
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
</template>
</SeriesGrantPackageTable>
</div>
<ElEmpty v-else description="暂无套餐" :image-size="80" />
</div>
@@ -214,95 +192,58 @@
<span>加载中...</span>
</div>
<!-- 添加/编辑套餐对话框 -->
<ElDialog
<SeriesGrantPackageDialog
v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="500px"
:close-on-click-modal="false"
v-model:form="packageForm"
:dialog-type="packageDialogType"
:rules="packageRules"
:available-packages="availablePackages"
:package-loading="packageLoading"
:submit-loading="submitLoading"
:can-update-expiry-base="canUpdateExpiryBase"
:has-series="Boolean(detailData?.series_id)"
@search="searchPackages"
@submit="handleSavePackage"
@closed="handlePackageDialogClosed"
/>
<ElDialog
v-model="expiryDialogVisible"
title="修改生效条件"
width="460px"
:close-on-click-modal="false"
>
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="100px">
<!-- 添加模式选择套餐 -->
<ElFormItem label="选择套餐" prop="package_id" v-if="packageDialogType === 'add'">
<ElSelect
v-model="packageForm.package_id"
placeholder="请选择套餐"
style="width: 100%"
filterable
remote
:remote-method="searchPackages"
:loading="packageLoading"
clearable
>
<template
v-if="availablePackages.length === 0 && !packageLoading && detailData?.series_id"
>
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in availablePackages"
:key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id"
:disabled="pkg.is_authorized"
/>
</ElSelect>
<ElForm label-width="130px">
<ElFormItem label="套餐名称">
<span class="ellipsis-value" :title="expiryForm.package_name || ''">
{{ expiryForm.package_name || '-' }}
</span>
</ElFormItem>
<!-- 编辑模式显示套餐信息 -->
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_name }}</span>
<ElFormItem label="套餐编码">
<span class="ellipsis-value" :title="expiryForm.package_code || ''">
{{ expiryForm.package_code || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐编码" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_code }}</span>
<ElFormItem label="套餐默认生效条件">
{{ expiryForm.default_expiry_base_name || '-' }}
</ElFormItem>
<!-- 成本价 -->
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价: ¥{{
packageForm.original_cost_price.toFixed(2)
}}
</div>
</ElFormItem>
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect
v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件"
style="width: 100%"
>
<ElFormItem label="覆盖生效条件">
<ElSelect v-model="expiryForm.expiry_base_override" style="width: 100%">
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
<div class="form-tip">仅影响后续新订单不影响已购买套餐</div>
</ElFormItem>
<template v-if="packageDialogType === 'edit'">
<ElFormItem label="套餐默认生效条件">
{{ packageForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
{{ packageForm.expiry_base_override_name || '跟随套餐默认' }}
</ElFormItem>
<ElFormItem label="最终生效条件">
{{ packageForm.effective_expiry_base_name || '-' }}
</ElFormItem>
</template>
<ElFormItem label="最终生效条件">
{{ expiryForm.effective_expiry_base_name || '-' }}
</ElFormItem>
<div class="form-tip">仅影响后续新订单不影响已购买套餐</div>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="packageDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSavePackage" :loading="submitLoading">
<ElButton @click="expiryDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="expirySubmitLoading" @click="saveExpiryBase">
保存
</ElButton>
</div>
@@ -330,10 +271,8 @@
ElDialog,
ElSelect,
ElOption,
ElInputNumber,
ElForm,
ElFormItem,
FormInstance,
FormRules
} from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
@@ -354,9 +293,13 @@
import { formatDateTime } from '@/utils/business/format'
import { getEnableStatusText, JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
import SeriesGrantPackageTable from '@/components/business/SeriesGrantPackageTable.vue'
import SeriesGrantPackageDialog from '@/components/business/SeriesGrantPackageDialog.vue'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate
type GrantPackageCandidate,
type PackageCostFormItem,
type SeriesGrantPackageForm
} from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantsDetail' })
@@ -374,61 +317,79 @@
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
const availablePackages = ref<GrantPackageCandidate[]>([])
const packageFormRef = ref<FormInstance>()
const expiryDialogVisible = ref(false)
const expirySubmitLoading = ref(false)
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
// 套餐表单
const packageForm = ref<{
type ExpiryFormState = {
package_id?: number
allocation_id?: number
package_name?: string
package_code?: string
original_cost_price?: number
cost_price_yuan: number
expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null
expiry_base_override: ExpiryBaseSelection
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
}>({
}
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const createDefaultPackageForm = (): SeriesGrantPackageForm => ({
package_id: undefined,
allocation_id: undefined,
package_ids: [],
package_name: undefined,
package_code: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default'
initial_expiry_base_override: 'default',
packages: []
})
const packageForm = ref<SeriesGrantPackageForm>(createDefaultPackageForm())
// 表单验证规则
const packageRules = computed<FormRules>(() => ({
package_id: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' }
package_ids: [
{
validator: (_rule, value, callback) => {
if (packageDialogType.value !== 'add') {
callback()
return
}
if (!Array.isArray(value) || value.length === 0) {
callback(new Error('请至少选择一个套餐'))
} else if (value.length > 100) {
callback(new Error('最多选择100个套餐'))
} else {
callback()
}
},
trigger: 'change'
}
],
cost_price_yuan: [
{ required: true, message: '请输入成本价', trigger: 'blur' },
{ type: 'number', min: 0, message: '成本价不能小于0', trigger: 'blur' }
{
required: packageDialogType.value === 'edit',
message: '请输入成本价',
trigger: 'blur'
},
{
validator: (_rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('请输入成本价'))
} else if (value < 0) {
callback(new Error('成本价不能小于0'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}))
// 梯度配置表格数据(合并授权数据和系列数据)
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value !== 'add' || !packageId) return
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
if (!selectedPackage) return
const originalCostPrice =
selectedPackage.cost_price !== undefined && selectedPackage.cost_price !== null
? selectedPackage.cost_price / 100
: 0
packageForm.value.package_name = selectedPackage.package_name
packageForm.value.package_code = selectedPackage.package_code
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
)
const tierTableData = computed(() => {
if (
!detailData.value?.commission_tiers ||
@@ -507,19 +468,31 @@
}
}
// 显示添加套餐对话框
const assignPackageForm = (values: Partial<SeriesGrantPackageForm> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
}
const resolvePackagePricing = (row?: GrantPackageInfo) => {
const pkgOption = availablePackages.value.find((pkg) => pkg.id === row?.package_id)
const currentCostPrice = row?.cost_price !== undefined ? row.cost_price / 100 : 0
return {
original_cost_price:
pkgOption?.cost_price !== undefined && pkgOption.cost_price !== null
? pkgOption.cost_price / 100
: currentCostPrice,
suggested_retail_price:
pkgOption?.suggested_retail_price !== undefined && pkgOption.suggested_retail_price !== null
? pkgOption.suggested_retail_price / 100
: undefined
}
}
// 显示添加套餐对话框
const showAddPackageDialog = () => {
packageDialogType.value = 'add'
packageForm.value = {
package_id: undefined,
allocation_id: undefined,
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default'
}
assignPackageForm({ package_ids: [], packages: [] })
// 加载可用套餐
if (detailData.value?.series_id) {
@@ -532,23 +505,44 @@
// 显示编辑套餐对话框
const showEditPackageDialog = (row: GrantPackageInfo) => {
packageDialogType.value = 'edit'
packageForm.value = {
const packagePricing = resolvePackagePricing(row)
assignPackageForm({
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
original_cost_price: row.cost_price / 100,
original_cost_price: packagePricing.original_cost_price,
cost_price_yuan: row.cost_price / 100,
suggested_retail_price: packagePricing.suggested_retail_price,
expiry_base_override: row.expiry_base_override ?? 'default',
initial_expiry_base_override: row.expiry_base_override ?? 'default',
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
}
})
packageDialogVisible.value = true
}
const showExpiryBaseDialog = (row: GrantPackageInfo) => {
if (row.allocation_id === undefined || row.allocation_id === null) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}
expiryForm.value = {
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override: row.expiry_base_override ?? 'default',
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
}
expiryDialogVisible.value = true
}
// 加载可用套餐
const loadAvailablePackages = async (packageName?: string) => {
if (!detailData.value?.series_id) return
@@ -596,79 +590,139 @@
}
}
const createPackageCostFormItem = (packageId: number): PackageCostFormItem => {
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
const originalCostPrice = selectedPackage?.cost_price ? selectedPackage.cost_price / 100 : 0
return {
package_id: packageId,
package_name: selectedPackage?.package_name,
package_code: selectedPackage?.package_code,
cost_price_yuan: originalCostPrice,
original_cost_price: originalCostPrice,
suggested_retail_price:
selectedPackage?.suggested_retail_price !== undefined &&
selectedPackage.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
}
}
const getPackageCostPriceMax = (pkg?: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg?.suggested_retail_price !== undefined && pkg.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg?.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
watch(
() => packageForm.value.package_ids,
(packageIds) => {
if (packageDialogType.value !== 'add') return
const existingItems = new Map(
packageForm.value.packages.map((item) => [item.package_id, item])
)
packageForm.value.packages = packageIds.map(
(packageId) => existingItems.get(packageId) || createPackageCostFormItem(packageId)
)
},
{ deep: true }
)
// 保存套餐
const handleSavePackage = async () => {
if (!packageFormRef.value || !detailData.value) return
if (!detailData.value) return
const grantId = detailData.value.id // 保存到常量中避免类型检查问题
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
if (packageDialogType.value === 'add') {
await ShopSeriesGrantService.manageGrantPackages(grantId, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages: [
{
package_id: packageForm.value.package_id!,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
}
]
})
} else {
const expiryBaseChanged =
packageForm.value.expiry_base_override !==
packageForm.value.initial_expiry_base_override
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
const grantId = detailData.value.id
submitLoading.value = true
try {
if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐')
return
}
const packages: GrantPackageItem[] = packageForm.value.packages.map((pkg) => {
const currentCostPrice = Number(pkg.cost_price_yuan)
if (!Number.isFinite(currentCostPrice) || currentCostPrice < 0) {
throw new Error(`套餐 ${pkg.package_name || pkg.package_id} 的成本价无效`)
}
const packages: GrantPackageItem[] = [
{
package_id: packageForm.value.package_id!,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
}
]
if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
packageForm.value.allocation_id!,
{
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
}
const maxCostPrice = getPackageCostPriceMax(pkg)
if (maxCostPrice !== undefined && currentCostPrice > maxCostPrice) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
)
}
await ShopSeriesGrantService.manageGrantPackages(grantId, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
if (pkg.original_cost_price !== undefined && currentCostPrice < pkg.original_cost_price) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
)
}
return {
package_id: pkg.package_id,
cost_price: Math.round(currentCostPrice * 100)
}
})
await ShopSeriesGrantService.manageGrantPackages(grantId, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
} else {
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const costPrice = Math.round(currentCostPriceYuan * 100)
const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.initial_expiry_base_override
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}
ElMessage.success(packageDialogType.value === 'add' ? '添加成功' : '更新成功')
packageDialogVisible.value = false
// 刷新详情数据
await fetchDetail()
} catch (error) {
console.error(error)
console.log(packageDialogType.value === 'add' ? '添加失败' : '更新失败')
} finally {
submitLoading.value = false
const packages: GrantPackageItem[] = [
{ package_id: packageForm.value.package_id!, cost_price: costPrice }
]
if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
packageForm.value.allocation_id!,
{
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
}
)
}
await ShopSeriesGrantService.manageGrantPackages(grantId, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
}
})
ElMessage.success(packageDialogType.value === 'add' ? '添加成功' : '更新成功')
packageDialogVisible.value = false
// 刷新详情数据
await fetchDetail()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
} finally {
submitLoading.value = false
}
}
// 删除套餐
@@ -690,7 +744,10 @@
}
]
await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, { packages })
await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, {
expiry_base_override: row.expiry_base_override ?? null,
packages
})
ElMessage.success('删除成功')
// 刷新详情数据
@@ -707,17 +764,35 @@
})
}
const saveExpiryBase = async () => {
if (expiryForm.value.allocation_id === undefined || expiryForm.value.allocation_id === null) {
return
}
expirySubmitLoading.value = true
try {
const response = await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
expiryForm.value.allocation_id,
{
expiry_base_override:
expiryForm.value.expiry_base_override === 'default'
? null
: expiryForm.value.expiry_base_override
}
)
if (response.code === 0) {
ElMessage.success('生效条件更新成功')
expiryDialogVisible.value = false
await fetchDetail()
}
} finally {
expirySubmitLoading.value = false
}
}
// 关闭对话框
const handlePackageDialogClosed = () => {
packageFormRef.value?.resetFields()
packageForm.value = {
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default'
}
assignPackageForm()
availablePackages.value = []
}
@@ -764,6 +839,13 @@
.package-table {
margin-top: 12px;
.package-table-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
}
.tier-table-wrapper {
@@ -794,6 +876,21 @@
color: var(--el-color-warning);
}
.ellipsis-value {
display: block;
width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-footer {
text-align: right;
}

View File

@@ -21,189 +21,60 @@
</ElButton>
</div>
<ArtTable
ref="tableRef"
row-key="package_id"
:loading="loading"
<SeriesGrantPackageTable
:data="packageList"
:marginTop="10"
:pagination="false"
:actions="getActions"
:actionsWidth="240"
:inlineActionsCount="3"
:loading="loading"
:show-actions="true"
:actions-width="240"
>
<template #default>
<ElTableColumn
prop="package_name"
label="套餐名称"
minWidth="220"
show-overflow-tooltip
/>
<ElTableColumn
prop="package_code"
label="套餐编码"
minWidth="180"
show-overflow-tooltip
/>
<ElTableColumn label="成本价" width="120">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="套餐默认生效条件" minWidth="150">
<template #default="{ row }">
{{ row.default_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="覆盖生效条件" minWidth="150">
<template #default="{ row }">
{{ row.expiry_base_override_name || '跟随套餐默认' }}
</template>
</ElTableColumn>
<ElTableColumn label="最终生效条件" minWidth="150">
<template #default="{ row }">
{{ row.effective_expiry_base_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<template #actions="{ row }">
<div class="package-table-actions">
<ElButton
v-if="canUpdateExpiryBase"
type="primary"
size="small"
link
@click="showExpiryBaseDialog(row)"
>
修改生效条件
</ElButton>
<ElButton
v-if="hasAuth('series_grants:edit_packages_list')"
type="primary"
size="small"
link
@click="showEditPackageDialog(row)"
>
编辑
</ElButton>
<ElButton
v-if="hasAuth('series_grants:delete_packages_list')"
type="danger"
size="small"
link
@click="handleDeletePackage(row)"
>
删除
</ElButton>
</div>
</template>
</ArtTable>
</SeriesGrantPackageTable>
</ElCard>
<ElDialog
<SeriesGrantPackageDialog
v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="40%"
:close-on-click-modal="false"
v-model:form="packageForm"
:dialog-type="packageDialogType"
:rules="packageRules"
:available-packages="availablePackages"
:package-loading="packageLoading"
:submit-loading="submitLoading"
:can-update-expiry-base="canUpdateExpiryBase"
:has-series="Boolean(seriesId)"
@search="searchAvailablePackages"
@submit="handleSavePackage"
@closed="handlePackageDialogClosed"
>
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="140px">
<ElFormItem label="选择套餐" prop="package_ids" v-if="packageDialogType === 'add'">
<ElSelect
v-model="packageForm.package_ids"
placeholder="请选择套餐(可多选)"
style="width: 100%"
filterable
remote
:remote-method="searchAvailablePackages"
:loading="packageLoading"
clearable
multiple
:multiple-limit="100"
>
<template v-if="availablePackages.length === 0 && !packageLoading && seriesId">
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in availablePackages"
:key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id"
:disabled="pkg.is_authorized"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="packageDialogType === 'add' && packageForm.packages.length"
label="套餐配置"
prop="packages"
>
<div class="package-config-list">
<div
v-for="pkg in packageForm.packages"
:key="pkg.package_id"
class="package-config-item"
>
<div class="package-config-name" :title="pkg.package_name || ''">
{{ pkg.package_name || '套餐名称不可用' }}
</div>
<div class="package-config-cost">
<span class="package-config-field-label">套餐成本价</span>
<ElInputNumber
v-model="pkg.cost_price_yuan"
:min="pkg.original_cost_price || 0"
:max="getPackageCostPriceMax(pkg)"
:precision="2"
:step="0.01"
:controls="false"
placeholder="请输入成本价"
style="width: 150px"
/>
</div>
</div>
</div>
</ElFormItem>
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_name }}</span>
</ElFormItem>
<ElFormItem label="套餐编码" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_code }}</span>
</ElFormItem>
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价: ¥{{
packageForm.original_cost_price.toFixed(2)
}}
</div>
</ElFormItem>
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect
v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件"
style="width: 100%"
>
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
<div class="form-tip">仅影响后续新订单不影响已购买套餐</div>
</ElFormItem>
<template v-if="packageDialogType === 'edit'">
<ElFormItem label="套餐默认生效条件">
{{ packageForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
{{ packageForm.expiry_base_override_name || '跟随套餐默认' }}
</ElFormItem>
<ElFormItem label="最终生效条件">
{{ packageForm.effective_expiry_base_name || '-' }}
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="packageDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSavePackage" :loading="submitLoading">
保存
</ElButton>
</div>
</template>
</ElDialog>
/>
<ElDialog
v-model="expiryDialogVisible"
@@ -252,22 +123,18 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElCard,
ElButton,
ElIcon,
ElTableColumn,
ElTag,
ElDialog,
ElSelect,
ElOption,
ElInputNumber,
ElForm,
ElFormItem,
ElMessage,
FormInstance,
FormRules
} from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
@@ -283,9 +150,13 @@
} from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants'
import SeriesGrantPackageTable from '@/components/business/SeriesGrantPackageTable.vue'
import SeriesGrantPackageDialog from '@/components/business/SeriesGrantPackageDialog.vue'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate
type GrantPackageCandidate,
type SeriesGrantPackageForm,
type PackageCostFormItem
} from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantPackages' })
@@ -298,15 +169,11 @@
const loading = ref(false)
const submitLoading = ref(false)
const packageLoading = ref(false)
const tableRef = ref()
const grantId = computed(() => Number(route.params.id))
const seriesId = ref<number>(0)
const grantName = ref<string>('')
const packageList = ref<GrantPackageInfo[]>([])
const availablePackages = ref<GrantPackageCandidate[]>([])
const packageFormRef = ref<FormInstance>()
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
const expiryDialogVisible = ref(false)
@@ -314,32 +181,6 @@
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
type PackageCostFormItem = {
package_id: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
}
type PackageFormState = {
package_id?: number
allocation_id?: number
package_ids: number[]
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
packages: PackageCostFormItem[]
}
type ExpiryFormState = {
package_id?: number
allocation_id?: number
@@ -351,7 +192,7 @@
effective_expiry_base_name?: string | null
}
const createDefaultPackageForm = (): PackageFormState => ({
const createDefaultPackageForm = (): SeriesGrantPackageForm => ({
package_id: undefined,
allocation_id: undefined,
package_ids: [],
@@ -362,10 +203,10 @@
initial_expiry_base_override: 'default',
packages: []
})
const packageForm = ref<PackageFormState>(createDefaultPackageForm())
const packageForm = ref<SeriesGrantPackageForm>(createDefaultPackageForm())
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const assignPackageForm = (values: Partial<PackageFormState> = {}) => {
const assignPackageForm = (values: Partial<SeriesGrantPackageForm> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
}
@@ -426,36 +267,6 @@
]
}))
const getActions = (row: GrantPackageInfo) => {
const actions: any[] = []
if (canUpdateExpiryBase) {
actions.push({
label: '修改生效条件',
handler: () => showExpiryBaseDialog(row),
type: 'primary'
})
}
if (hasAuth('series_grants:edit_packages_list')) {
actions.push({
label: '编辑',
handler: () => showEditPackageDialog(row),
type: 'primary'
})
}
if (hasAuth('series_grants:delete_packages_list')) {
actions.push({
label: '删除',
handler: () => handleDeletePackage(row),
type: 'danger'
})
}
return actions
}
// 返回上一页
const handleBack = () => {
router.back()
@@ -484,7 +295,6 @@
assignPackageForm({ package_ids: [], packages: [] })
loadAvailablePackages()
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
}
const showEditPackageDialog = (row: GrantPackageInfo) => {
@@ -506,7 +316,6 @@
effective_expiry_base_name: row.effective_expiry_base_name
})
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
}
const showExpiryBaseDialog = (row: GrantPackageInfo) => {
@@ -605,120 +414,90 @@
{ deep: true }
)
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value === 'add' && packageId) {
const selectedPackage = availablePackages.value.find((p) => p.id === packageId)
if (selectedPackage) {
const originalCostPrice = selectedPackage.cost_price
? selectedPackage.cost_price / 100
: 0
const suggestedRetailPrice =
selectedPackage.suggested_retail_price !== undefined &&
selectedPackage.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.suggested_retail_price = suggestedRetailPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
}
}
)
const handleSavePackage = async () => {
if (!packageFormRef.value || !grantId.value) return
if (!grantId.value) return
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
submitLoading.value = true
try {
if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐')
return
}
const packages: GrantPackageItem[] = packageForm.value.packages.map((pkg) => {
const currentCostPrice = Number(pkg.cost_price_yuan)
if (!Number.isFinite(currentCostPrice) || currentCostPrice < 0) {
throw new Error(`套餐 ${pkg.package_name || pkg.package_id} 的成本价无效`)
}
const maxCostPrice = getPackageCostPriceMax(pkg)
if (maxCostPrice !== undefined && currentCostPrice > maxCostPrice) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
)
}
if (pkg.original_cost_price !== undefined && currentCostPrice < pkg.original_cost_price) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
)
}
return {
package_id: pkg.package_id,
cost_price: Math.round(currentCostPrice * 100)
}
})
await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
} else {
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const costPrice = Math.round(currentCostPriceYuan * 100)
if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐')
return
}
const packages: GrantPackageItem[] = packageForm.value.packages.map((pkg) => {
const currentCostPrice = Number(pkg.cost_price_yuan)
if (!Number.isFinite(currentCostPrice) || currentCostPrice < 0) {
throw new Error(`套餐 ${pkg.package_name || pkg.package_id} 的成本价无效`)
}
const maxCostPrice = getPackageCostPriceMax(pkg)
if (maxCostPrice !== undefined && currentCostPrice > maxCostPrice) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
)
}
if (
pkg.original_cost_price !== undefined &&
currentCostPrice < pkg.original_cost_price
) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
)
}
return {
package_id: pkg.package_id,
cost_price: Math.round(currentCostPrice * 100)
}
})
await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
} else {
const expiryBaseChanged =
packageForm.value.expiry_base_override !==
packageForm.value.initial_expiry_base_override
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}
const packages: GrantPackageItem[] = [
{ package_id: packageForm.value.package_id!, cost_price: costPrice }
]
if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
packageForm.value.allocation_id!,
{
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
}
)
}
await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.initial_expiry_base_override
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}
packageDialogVisible.value = false
await fetchPackageList()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
} finally {
submitLoading.value = false
const packages: GrantPackageItem[] = [
{ package_id: packageForm.value.package_id!, cost_price: costPrice }
]
if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
packageForm.value.allocation_id!,
{
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
}
)
}
await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
}
})
packageDialogVisible.value = false
await fetchPackageList()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
} finally {
submitLoading.value = false
}
}
const saveExpiryBase = async () => {
@@ -779,7 +558,6 @@
}
const handlePackageDialogClosed = () => {
packageFormRef.value?.clearValidate()
assignPackageForm()
availablePackages.value = []
}
@@ -827,9 +605,11 @@
}
}
.amount-value {
font-weight: 600;
color: var(--el-color-warning);
.package-table-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.form-tip {
@@ -838,51 +618,15 @@
color: var(--el-text-color-secondary);
}
.package-config-list {
width: 100%;
}
.package-config-item {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.package-config-name,
.ellipsis-value {
display: block;
width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-config-name {
flex: 1;
}
.package-config-cost {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
}
.package-config-field-label {
color: var(--el-text-color-secondary);
font-size: 13px;
white-space: nowrap;
}
.ellipsis-value {
display: block;
width: 260px;
}
.dialog-footer {
text-align: right;
}