fix(plans): subscription plan page validation, sorting, and toFixed error

- Fix toFixed crash when price is undefined
- Remove sort_order field, order by created_at instead
- Fix group selector showing blank when nothing selected
- Add required field markers (*) and backend validation
- Add i18n groupRequired key for both zh/en
This commit is contained in:
erio
2026-04-13 18:08:06 +08:00
parent d96cbe4f21
commit 5277f33155
5 changed files with 48 additions and 22 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.110.34
0.1.110.35
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/group"
@@ -10,6 +11,26 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// validatePlanRequired checks that all required fields for a plan are provided.
func validatePlanRequired(name string, groupID int64, price float64, validityDays int, validityUnit string) error {
if strings.TrimSpace(name) == "" {
return infraerrors.BadRequest("PLAN_NAME_REQUIRED", "plan name is required")
}
if groupID <= 0 {
return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "group is required")
}
if price < 0 {
return infraerrors.BadRequest("PLAN_PRICE_INVALID", "price must be >= 0")
}
if validityDays <= 0 {
return infraerrors.BadRequest("PLAN_VALIDITY_REQUIRED", "validity days must be > 0")
}
if strings.TrimSpace(validityUnit) == "" {
return infraerrors.BadRequest("PLAN_VALIDITY_UNIT_REQUIRED", "validity unit is required")
}
return nil
}
// --- Plan CRUD ---
// PlanGroupInfo holds the group details needed for subscription plan display.
@@ -66,14 +87,17 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
}
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.ByCreatedAt()).All(ctx)
}
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.ByCreatedAt()).All(ctx)
}
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
if err := validatePlanRequired(req.Name, req.GroupID, req.Price, req.ValidityDays, req.ValidityUnit); err != nil {
return nil, err
}
b := s.entClient.SubscriptionPlan.Create().
SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
+1
View File
@@ -5412,6 +5412,7 @@ export default {
tabPlanConfig: 'Plan Configuration',
tabUserSubs: 'User Subscriptions',
selectGroup: 'Select a group',
groupRequired: 'Please select a subscription group',
groupMissing: 'Missing',
groupInfo: 'Group Info',
platform: 'Platform',
+1
View File
@@ -5607,6 +5607,7 @@ export default {
tabPlanConfig: '套餐配置',
tabUserSubs: '用户订阅',
selectGroup: '请选择分组',
groupRequired: '请选择订阅分组',
groupMissing: '缺失',
groupInfo: '分组信息',
platform: '平台',
@@ -29,7 +29,7 @@
</template>
<template #cell-price="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ (value ?? 0).toFixed(2) }}</span>
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
</div>
</template>
@@ -71,12 +71,12 @@
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('payment.admin.planName') }}</label>
<label class="input-label">{{ t('payment.admin.planName') }} <span class="text-red-500">*</span></label>
<input v-model="planForm.name" type="text" class="input" required />
</div>
<div>
<label class="input-label">{{ t('payment.admin.group') }}</label>
<Select v-model="planForm.group_id" :options="groupOptions" class="w-full">
<label class="input-label">{{ t('payment.admin.group') }} <span class="text-red-500">*</span></label>
<Select v-model="planForm.group_id" :options="groupOptions" :placeholder="t('payment.admin.selectGroup')" class="w-full">
<template #selected="{ option }">
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
@@ -101,15 +101,14 @@
</div>
</div>
<div><label class="input-label">{{ t('payment.admin.planDescription') }}</label><textarea v-model="planForm.description" rows="2" class="input"></textarea></div>
<div class="grid grid-cols-3 gap-4">
<div><label class="input-label">{{ t('payment.admin.price') }}</label><input v-model.number="planForm.price" type="number" step="0.01" min="0" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.planDescription') }} <span class="text-red-500">*</span></label><textarea v-model="planForm.description" rows="2" class="input" required></textarea></div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.price" type="number" step="0.01" min="0" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.validityDays') }}</label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }}</label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
<div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
</div>
<div>
<label class="input-label">{{ t('payment.admin.features') }}</label>
@@ -190,16 +189,15 @@ function getPlanNameClass(groupId: number): string {
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
}
const groupOptions = computed(() => [
{ value: 0, label: t('payment.admin.selectGroup'), platform: '' },
...groups.value
const groupOptions = computed(() =>
groups.value
.filter(g => g.subscription_type === 'subscription')
.map(g => ({
value: g.id,
label: `${g.name} — ${g.platform} (${g.rate_multiplier}x)`,
platform: g.platform,
})),
])
)
const selectedGroupInfo = computed(() => {
if (!planForm.group_id) return null
@@ -215,7 +213,7 @@ const showDeletePlanDialog = ref(false)
const planSaving = ref(false)
const editingPlan = ref<SubscriptionPlan | null>(null)
const deletingPlanId = ref<number | null>(null)
const planForm = reactive({ name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true })
const planFeaturesText = ref('')
const validityUnitOptions = computed(() => [
@@ -231,7 +229,6 @@ const planColumns = computed((): Column[] => [
{ key: 'price', label: t('payment.admin.price') },
{ key: 'validity_days', label: t('payment.admin.validityDays') },
{ key: 'for_sale', label: t('payment.admin.forSale') },
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
{ key: 'actions', label: t('common.actions') },
])
@@ -254,10 +251,10 @@ async function loadPlans() {
function openPlanEdit(plan: SubscriptionPlan | null) {
editingPlan.value = plan
if (plan) {
Object.assign(planForm, { name: plan.name, group_id: plan.group_id, description: plan.description, price: plan.price, original_price: plan.original_price || 0, validity_days: plan.validity_days, validity_unit: plan.validity_unit || 'days', for_sale: plan.for_sale, sort_order: plan.sort_order })
Object.assign(planForm, { name: plan.name, group_id: plan.group_id, description: plan.description, price: plan.price, original_price: plan.original_price || 0, validity_days: plan.validity_days, validity_unit: plan.validity_unit || 'days', for_sale: plan.for_sale })
planFeaturesText.value = (plan.features || []).join('\n')
} else {
Object.assign(planForm, { name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true })
planFeaturesText.value = ''
}
showPlanDialog.value = true
@@ -275,12 +272,15 @@ function buildPlanPayload() {
validity_days: planForm.validity_days,
validity_unit: planForm.validity_unit,
for_sale: planForm.for_sale,
sort_order: planForm.sort_order,
features,
}
}
async function handleSavePlan() {
if (!planForm.group_id) {
appStore.showError(t('payment.admin.groupRequired'))
return
}
planSaving.value = true
try {
const data = buildPlanPayload()