Merge pull request #2573 from wucm667/feat/redeem-code-expiry

feat(redeem): 兑换码支持设置使用有效期
This commit is contained in:
Wesley Liddick
2026-05-19 16:25:12 +08:00
committed by GitHub
27 changed files with 676 additions and 38 deletions
+6 -1
View File
@@ -60,6 +60,7 @@ export async function getById(id: number): Promise<RedeemCode> {
* @param value - Value of the code
* @param groupId - Group ID (required for subscription type)
* @param validityDays - Validity days (for subscription type)
* @param expiresInDays - Days before the code itself expires
* @returns Array of generated redeem codes
*/
export async function generate(
@@ -67,7 +68,8 @@ export async function generate(
type: RedeemCodeType,
value: number,
groupId?: number | null,
validityDays?: number
validityDays?: number,
expiresInDays?: number | null
): Promise<RedeemCode[]> {
const payload: GenerateRedeemCodesRequest = {
count,
@@ -82,6 +84,9 @@ export async function generate(
payload.validity_days = validityDays
}
}
if (expiresInDays && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload)
return data
+7
View File
@@ -4115,6 +4115,7 @@ export default {
status: 'Status',
usedBy: 'Used By',
usedAt: 'Used At',
expiresAt: 'Expires At',
actions: 'Actions'
},
userPrefix: 'User #{id}',
@@ -4160,6 +4161,12 @@ export default {
selectGroup: 'Select Group',
selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days',
codeExpiry: 'Code Expiry',
neverExpires: 'Never expires',
expiryPresetDays: '{days} days',
customExpiry: 'Custom',
customExpiryDays: 'Custom days',
expiryDaysRequired: 'Please enter a valid expiry day count',
groupRequired: 'Please select a subscription group',
days: ' days',
status: {
+7
View File
@@ -4229,6 +4229,7 @@ export default {
status: '状态',
usedBy: '使用者',
usedAt: '使用时间',
expiresAt: '过期时间',
createdAt: '创建时间',
actions: '操作'
},
@@ -4278,6 +4279,12 @@ export default {
selectGroup: '选择分组',
selectGroupPlaceholder: '选择订阅分组',
validityDays: '有效天数',
codeExpiry: '兑换码过期',
neverExpires: '永不过期',
expiryPresetDays: '{days} 天',
customExpiry: '自定义',
customExpiryDays: '自定义天数',
expiryDaysRequired: '请输入有效的过期天数',
groupRequired: '请选择订阅分组',
days: '天',
status: {
+3
View File
@@ -1293,6 +1293,7 @@ export interface RedeemCode {
used_by: number | null
used_at: string | null
created_at: string
expires_at?: string | null
updated_at?: string
group_id?: number | null // 订阅类型专用
validity_days?: number // 订阅类型专用
@@ -1306,6 +1307,8 @@ export interface GenerateRedeemCodesRequest {
value: number
group_id?: number | null // 订阅类型专用
validity_days?: number // 订阅类型专用
expires_at?: string | null
expires_in_days?: number
}
export interface RedeemCodeRequest {
+82 -2
View File
@@ -137,6 +137,19 @@
}}</span>
</template>
<template #cell-expires_at="{ value, row }">
<span
:class="[
'text-sm',
row.status === 'expired'
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-dark-400'
]"
>
{{ value ? formatDateTime(value) : t('admin.redeem.neverExpires') }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center space-x-2">
<button
@@ -287,6 +300,35 @@
/>
</div>
</template>
<div>
<label class="input-label">{{ t('admin.redeem.codeExpiry') }}</label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-5">
<button
v-for="option in redeemCodeExpiryOptions"
:key="option.value"
type="button"
@click="generateForm.expiry_option = option.value"
:class="[
'rounded-lg border px-3 py-2 text-sm transition-colors',
generateForm.expiry_option === option.value
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-900/20 dark:text-primary-300'
: 'border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-dark-600 dark:text-gray-300 dark:hover:bg-dark-700'
]"
>
{{ option.label }}
</button>
</div>
<input
v-if="generateForm.expiry_option === 'custom'"
v-model.number="generateForm.custom_expiry_days"
type="number"
min="1"
max="3650"
required
class="input mt-2"
:placeholder="t('admin.redeem.customExpiryDays')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.redeem.count') }}</label>
<input
@@ -504,6 +546,7 @@ const columns = computed<Column[]>(() => [
{ key: 'status', label: t('admin.redeem.columns.status'), sortable: true },
{ key: 'used_by', label: t('admin.redeem.columns.usedBy') },
{ key: 'used_at', label: t('admin.redeem.columns.usedAt'), sortable: true },
{ key: 'expires_at', label: t('admin.redeem.columns.expiresAt'), sortable: true },
{ key: 'actions', label: t('admin.redeem.columns.actions') }
])
@@ -555,12 +598,24 @@ const showDeleteUnusedDialog = ref(false)
const deletingCode = ref<RedeemCode | null>(null)
const copiedCode = ref<string | null>(null)
type RedeemCodeExpiryOption = 'never' | '1' | '3' | '7' | 'custom'
const redeemCodeExpiryOptions = computed<{ value: RedeemCodeExpiryOption; label: string }[]>(() => [
{ value: 'never', label: t('admin.redeem.neverExpires') },
{ value: '1', label: t('admin.redeem.expiryPresetDays', { days: 1 }) },
{ value: '3', label: t('admin.redeem.expiryPresetDays', { days: 3 }) },
{ value: '7', label: t('admin.redeem.expiryPresetDays', { days: 7 }) },
{ value: 'custom', label: t('admin.redeem.customExpiry') }
])
const generateForm = reactive({
type: 'balance' as RedeemCodeType,
value: 10,
count: 1,
group_id: null as number | null,
validity_days: 30
validity_days: 30,
expiry_option: 'never' as RedeemCodeExpiryOption,
custom_expiry_days: 7
})
// 监听类型变化,邀请码类型时自动设置 value 为 0
@@ -650,6 +705,22 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
loadCodes()
}
const getRedeemCodeExpiresInDays = () => {
if (generateForm.expiry_option === 'never') {
return undefined
}
if (generateForm.expiry_option === 'custom') {
if (
!Number.isFinite(generateForm.custom_expiry_days) ||
generateForm.custom_expiry_days < 1
) {
return null
}
return Math.floor(generateForm.custom_expiry_days)
}
return Number(generateForm.expiry_option)
}
const handleGenerateCodes = async () => {
// 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) {
@@ -657,6 +728,12 @@ const handleGenerateCodes = async () => {
return
}
const expiresInDays = getRedeemCodeExpiresInDays()
if (expiresInDays === null) {
appStore.showError(t('admin.redeem.expiryDaysRequired'))
return
}
generating.value = true
try {
const result = await adminAPI.redeem.generate(
@@ -664,7 +741,8 @@ const handleGenerateCodes = async () => {
generateForm.type,
generateForm.value,
generateForm.type === 'subscription' ? generateForm.group_id : undefined,
generateForm.type === 'subscription' ? generateForm.validity_days : undefined
generateForm.type === 'subscription' ? generateForm.validity_days : undefined,
expiresInDays
)
showGenerateDialog.value = false
generatedCodes.value = result
@@ -672,6 +750,8 @@ const handleGenerateCodes = async () => {
// 重置表单
generateForm.group_id = null
generateForm.validity_days = 30
generateForm.expiry_option = 'never'
generateForm.custom_expiry_days = 7
loadCodes()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToGenerate'))