Files
one-pipe-system/src/views/package-management/series-grants/packages.vue
luo 68aa03b538
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 9m23s
feat: 套餐分配生效条件选择
2026-07-22 14:21:15 +08:00

579 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ArtTableFullScreen>
<div class="grant-package-list-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="page-header">
<div class="header-left">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<span class="grant-name">{{ grantName }}</span>
</div>
<ElButton
type="primary"
@click="showAddPackageDialog"
v-permission="'series_grants:manage_packages_list'"
>
添加授权套餐
</ElButton>
</div>
<ArtTable
ref="tableRef"
row-key="package_id"
:loading="loading"
:data="packageList"
:marginTop="10"
:pagination="false"
:actions="getActions"
:actionsWidth="150"
:inlineActionsCount="2"
>
<template #default>
<ElTableColumn prop="package_name" label="套餐名称" minWidth="150" />
<ElTableColumn prop="package_code" label="套餐编码" minWidth="120" />
<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>
</ArtTable>
</ElCard>
<ElDialog
v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="500px"
:close-on-click-modal="false"
@closed="handlePackageDialogClosed"
>
<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="searchAvailablePackages"
:loading="packageLoading"
clearable
>
<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="isPackageAlreadyAdded(pkg.id)"
/>
</ElSelect>
</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 label="生效条件">
<ElRadioGroup v-model="packageForm.expiry_base_override">
<ElRadio value="default">跟随套餐默认</ElRadio>
<ElRadio value="from_purchase">购买即生效</ElRadio>
<ElRadio value="from_realname">实名即生效</ElRadio>
</ElRadioGroup>
<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>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElCard,
ElButton,
ElIcon,
ElTableColumn,
ElTag,
ElDialog,
ElSelect,
ElOption,
ElInputNumber,
ElForm,
ElFormItem,
ElRadioGroup,
ElRadio,
ElMessage,
FormInstance,
FormRules
} from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import {
ShopSeriesGrantService,
ShopPackageAllocationService,
PackageManageService
} from '@/api/modules'
import type {
GrantPackageInfo,
GrantPackageItem,
PackageAllocationExpiryBaseOverride,
PackageResponse
} from '@/types/api'
import { useAuth } from '@/composables/useAuth'
defineOptions({ name: 'SeriesGrantPackages' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
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 shopId = ref<number>(0)
const grantName = ref<string>('')
const packageList = ref<GrantPackageInfo[]>([])
const availablePackages = ref<PackageResponse[]>([])
const packageFormRef = ref<FormInstance>()
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
type PackageFormState = {
package_id?: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
allocation_id?: 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
}
const createDefaultPackageForm = (): PackageFormState => ({
package_id: undefined,
package_name: undefined,
package_code: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default'
})
const packageForm = ref<PackageFormState>(createDefaultPackageForm())
const assignPackageForm = (values: Partial<PackageFormState> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
}
const resolvePackagePricing = (row?: GrantPackageInfo) => {
const pkgOption = availablePackages.value.find((p) => p.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 packageRules = computed<FormRules>(() => ({
package_id: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' }
],
cost_price_yuan: [
{ required: true, 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'
}
]
}))
const getActions = (row: GrantPackageInfo) => {
const actions: any[] = []
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()
}
const fetchPackageList = async () => {
if (!grantId.value) return
loading.value = true
try {
const res = await ShopSeriesGrantService.getShopSeriesGrantDetail(grantId.value)
if (res.code === 0) {
packageList.value = res.data.packages || []
seriesId.value = res.data.series_id
shopId.value = res.data.shop_id
grantName.value = `${res.data.series_name} - ${res.data.shop_name}`
}
} catch (error) {
console.error('获取套餐列表失败:', error)
} finally {
loading.value = false
}
}
const showAddPackageDialog = () => {
packageDialogType.value = 'add'
assignPackageForm()
loadAvailablePackages()
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
}
const showEditPackageDialog = (row: GrantPackageInfo) => {
packageDialogType.value = 'edit'
const packagePricing = resolvePackagePricing(row)
assignPackageForm({
package_id: row.package_id,
package_name: row.package_name,
package_code: row.package_code,
cost_price_yuan: row.cost_price / 100,
original_cost_price: packagePricing.original_cost_price,
suggested_retail_price: packagePricing.suggested_retail_price,
allocation_id: row.allocation_id,
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
nextTick(() => packageFormRef.value?.clearValidate())
}
const loadAvailablePackages = async (packageName?: string) => {
if (!seriesId.value) return
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 50,
series_id: seriesId.value
}
if (packageName) {
params.package_name = packageName
}
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
availablePackages.value = res.data.items
}
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {
packageLoading.value = false
}
}
const searchAvailablePackages = (query: string) => {
if (query) {
loadAvailablePackages(query)
} else {
loadAvailablePackages()
}
}
const isPackageAlreadyAdded = (packageId: number) => {
return packageList.value.some((p) => p.package_id === packageId)
}
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
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
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') {
await ShopPackageAllocationService.createShopPackageAllocation({
shop_id: shopId.value,
package_id: packageForm.value.package_id!,
cost_price: costPrice,
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
})
} else {
const expiryBaseChanged =
packageForm.value.expiry_base_override !==
packageForm.value.initial_expiry_base_override
if (expiryBaseChanged && !packageForm.value.allocation_id) {
ElMessage.error('该套餐分配记录缺少ID无法更新生效条件')
return
}
const packages: GrantPackageItem[] = [
{ package_id: packageForm.value.package_id, cost_price: costPrice }
]
await Promise.all([
ShopSeriesGrantService.manageGrantPackages(grantId.value, { packages }),
...(expiryBaseChanged
? [
ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
packageForm.value.allocation_id!,
{
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
}
)
]
: [])
])
}
packageDialogVisible.value = false
await fetchPackageList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
})
}
const handleDeletePackage = (row: GrantPackageInfo) => {
import('element-plus').then(({ ElMessageBox }) => {
ElMessageBox.confirm(`确定删除套餐 ${row.package_name} 的授权吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
submitLoading.value = true
try {
const packages: GrantPackageItem[] = [
{
package_id: row.package_id,
remove: true
}
]
await ShopSeriesGrantService.manageGrantPackages(grantId.value, { packages })
await fetchPackageList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
})
.catch(() => {})
})
}
const handlePackageDialogClosed = () => {
packageFormRef.value?.clearValidate()
assignPackageForm()
availablePackages.value = []
}
onMounted(() => {
fetchPackageList()
})
</script>
<style scoped lang="scss">
.grant-package-list-page {
height: 100%;
padding: 16px;
:deep(.art-table-card) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
.el-card__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.header-left {
display: flex;
gap: 16px;
align-items: center;
}
.grant-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.amount-value {
font-weight: 600;
color: var(--el-color-warning);
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-footer {
text-align: right;
}
}
</style>