feat: 实现注册优惠码功能

- 支持创建/编辑/删除优惠码,设置赠送金额和使用限制
  - 注册页面实时验证优惠码并显示赠送金额
  - 支持 URL 参数自动填充 (?promo=CODE)
  - 添加优惠码验证接口速率限制
  - 使用数据库行锁防止并发超限
  - 新增后台优惠码管理页面,支持复制注册链接
This commit is contained in:
long
2026-01-10 13:14:35 +08:00
parent 7d1fe818be
commit d2fc14fb97
79 changed files with 17045 additions and 54 deletions

5304
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import groupsAPI from './groups'
import accountsAPI from './accounts'
import proxiesAPI from './proxies'
import redeemAPI from './redeem'
import promoAPI from './promo'
import settingsAPI from './settings'
import systemAPI from './system'
import subscriptionsAPI from './subscriptions'
@@ -27,6 +28,7 @@ export const adminAPI = {
accounts: accountsAPI,
proxies: proxiesAPI,
redeem: redeemAPI,
promo: promoAPI,
settings: settingsAPI,
system: systemAPI,
subscriptions: subscriptionsAPI,
@@ -43,6 +45,7 @@ export {
accountsAPI,
proxiesAPI,
redeemAPI,
promoAPI,
settingsAPI,
systemAPI,
subscriptionsAPI,

View File

@@ -0,0 +1,69 @@
/**
* Admin Promo Codes API endpoints
*/
import { apiClient } from '../client'
import type {
PromoCode,
PromoCodeUsage,
CreatePromoCodeRequest,
UpdatePromoCodeRequest,
BasePaginationResponse
} from '@/types'
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: string
search?: string
}
): Promise<BasePaginationResponse<PromoCode>> {
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
params: { page, page_size: pageSize, ...filters }
})
return data
}
export async function getById(id: number): Promise<PromoCode> {
const { data } = await apiClient.get<PromoCode>(`/admin/promo-codes/${id}`)
return data
}
export async function create(request: CreatePromoCodeRequest): Promise<PromoCode> {
const { data } = await apiClient.post<PromoCode>('/admin/promo-codes', request)
return data
}
export async function update(id: number, request: UpdatePromoCodeRequest): Promise<PromoCode> {
const { data } = await apiClient.put<PromoCode>(`/admin/promo-codes/${id}`, request)
return data
}
export async function deleteCode(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/promo-codes/${id}`)
return data
}
export async function getUsages(
id: number,
page: number = 1,
pageSize: number = 20
): Promise<BasePaginationResponse<PromoCodeUsage>> {
const { data } = await apiClient.get<BasePaginationResponse<PromoCodeUsage>>(
`/admin/promo-codes/${id}/usages`,
{ params: { page, page_size: pageSize } }
)
return data
}
const promoAPI = {
list,
getById,
create,
update,
delete: deleteCode,
getUsages
}
export default promoAPI

View File

@@ -113,6 +113,26 @@ export async function sendVerifyCode(
return data
}
/**
* Validate promo code response
*/
export interface ValidatePromoCodeResponse {
valid: boolean
bonus_amount?: number
error_code?: string
message?: string
}
/**
* Validate promo code (public endpoint, no auth required)
* @param code - Promo code to validate
* @returns Validation result with bonus amount if valid
*/
export async function validatePromoCode(code: string): Promise<ValidatePromoCodeResponse> {
const { data } = await apiClient.post<ValidatePromoCodeResponse>('/auth/validate-promo-code', { code })
return data
}
export const authAPI = {
login,
register,
@@ -123,7 +143,8 @@ export const authAPI = {
getAuthToken,
clearAuthToken,
getPublicSettings,
sendVerifyCode
sendVerifyCode,
validatePromoCode
}
export default authAPI

View File

@@ -448,6 +448,7 @@ const adminNavItems = computed(() => {
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
]

View File

@@ -145,7 +145,8 @@ export default {
copiedToClipboard: 'Copied to clipboard',
copyFailed: 'Failed to copy',
contactSupport: 'Contact Support',
selectOption: 'Select an option',
optional: 'optional',
selectOption: 'Select an option',
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
noGroupsAvailable: 'No groups available',
@@ -177,6 +178,7 @@ export default {
accounts: 'Accounts',
proxies: 'Proxies',
redeemCodes: 'Redeem Codes',
promoCodes: 'Promo Codes',
settings: 'Settings',
myAccount: 'My Account',
lightMode: 'Light Mode',
@@ -229,6 +231,17 @@ export default {
sendingCode: 'Sending...',
clickToResend: 'Click to resend code',
resendCode: 'Resend verification code',
promoCodeLabel: 'Promo Code',
promoCodePlaceholder: 'Enter promo code (optional)',
promoCodeValid: 'Valid! You will receive ${amount} bonus balance',
promoCodeInvalid: 'Invalid promo code',
promoCodeNotFound: 'Promo code not found',
promoCodeExpired: 'This promo code has expired',
promoCodeDisabled: 'This promo code is disabled',
promoCodeMaxUsed: 'This promo code has reached its usage limit',
promoCodeAlreadyUsed: 'You have already used this promo code',
promoCodeValidating: 'Promo code is being validated, please wait',
promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field',
linuxdo: {
signIn: 'Continue with Linux.do',
orContinue: 'or continue with email',
@@ -1722,6 +1735,65 @@ export default {
}
},
// Promo Codes
promo: {
title: 'Promo Code Management',
description: 'Create and manage registration promo codes',
createCode: 'Create Promo Code',
editCode: 'Edit Promo Code',
deleteCode: 'Delete Promo Code',
searchCodes: 'Search codes...',
allStatus: 'All Status',
columns: {
code: 'Code',
bonusAmount: 'Bonus Amount',
maxUses: 'Max Uses',
usedCount: 'Used',
usage: 'Usage',
status: 'Status',
expiresAt: 'Expires At',
createdAt: 'Created At',
actions: 'Actions'
},
// Form labels (flat structure for template usage)
code: 'Promo Code',
autoGenerate: 'auto-generate if empty',
codePlaceholder: 'Enter promo code or leave empty',
bonusAmount: 'Bonus Amount ($)',
maxUses: 'Max Uses',
zeroUnlimited: '0 = unlimited',
expiresAt: 'Expires At',
notes: 'Notes',
notesPlaceholder: 'Optional notes for this code',
status: 'Status',
neverExpires: 'Never expires',
// Status labels
statusActive: 'Active',
statusDisabled: 'Disabled',
statusExpired: 'Expired',
statusMaxUsed: 'Used Up',
// Usage records
usageRecords: 'Usage Records',
viewUsages: 'View Usages',
noUsages: 'No usage records yet',
userPrefix: 'User #{id}',
copied: 'Copied!',
// Messages
noCodesYet: 'No promo codes yet',
createFirstCode: 'Create your first promo code to offer registration bonuses.',
codeCreated: 'Promo code created successfully',
codeUpdated: 'Promo code updated successfully',
codeDeleted: 'Promo code deleted successfully',
deleteCodeConfirm: 'Are you sure you want to delete this promo code? This action cannot be undone.',
copyRegisterLink: 'Copy register link',
registerLinkCopied: 'Register link copied to clipboard',
failedToLoad: 'Failed to load promo codes',
failedToCreate: 'Failed to create promo code',
failedToUpdate: 'Failed to update promo code',
failedToDelete: 'Failed to delete promo code',
failedToLoadUsages: 'Failed to load usage records'
},
// Usage Records
usage: {
title: 'Usage Records',

View File

@@ -142,6 +142,7 @@ export default {
copiedToClipboard: '已复制到剪贴板',
copyFailed: '复制失败',
contactSupport: '联系客服',
optional: '可选',
selectOption: '请选择',
searchPlaceholder: '搜索...',
noOptionsFound: '无匹配选项',
@@ -175,6 +176,7 @@ export default {
accounts: '账号管理',
proxies: 'IP管理',
redeemCodes: '兑换码',
promoCodes: '优惠码',
settings: '系统设置',
myAccount: '我的账户',
lightMode: '浅色模式',
@@ -227,6 +229,17 @@ export default {
sendingCode: '发送中...',
clickToResend: '点击重新发送验证码',
resendCode: '重新发送验证码',
promoCodeLabel: '优惠码',
promoCodePlaceholder: '输入优惠码(可选)',
promoCodeValid: '有效!注册后将获得 ${amount} 赠送余额',
promoCodeInvalid: '无效的优惠码',
promoCodeNotFound: '优惠码不存在',
promoCodeExpired: '此优惠码已过期',
promoCodeDisabled: '此优惠码已被禁用',
promoCodeMaxUsed: '此优惠码已达到使用上限',
promoCodeAlreadyUsed: '您已使用过此优惠码',
promoCodeValidating: '优惠码正在验证中,请稍候',
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
linuxdo: {
signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续',
@@ -1867,6 +1880,65 @@ export default {
failedToDelete: '删除兑换码失败'
},
// Promo Codes
promo: {
title: '优惠码管理',
description: '创建和管理注册优惠码',
createCode: '创建优惠码',
editCode: '编辑优惠码',
deleteCode: '删除优惠码',
searchCodes: '搜索优惠码...',
allStatus: '全部状态',
columns: {
code: '优惠码',
bonusAmount: '赠送金额',
maxUses: '最大使用次数',
usedCount: '已使用',
usage: '使用量',
status: '状态',
expiresAt: '过期时间',
createdAt: '创建时间',
actions: '操作'
},
// 表单标签(扁平结构便于模板使用)
code: '优惠码',
autoGenerate: '留空自动生成',
codePlaceholder: '输入优惠码或留空',
bonusAmount: '赠送金额 ($)',
maxUses: '最大使用次数',
zeroUnlimited: '0 = 无限制',
expiresAt: '过期时间',
notes: '备注',
notesPlaceholder: '可选备注信息',
status: '状态',
neverExpires: '永不过期',
// 状态标签
statusActive: '启用',
statusDisabled: '禁用',
statusExpired: '已过期',
statusMaxUsed: '已用完',
// 使用记录
usageRecords: '使用记录',
viewUsages: '查看使用记录',
noUsages: '暂无使用记录',
userPrefix: '用户 #{id}',
copied: '已复制!',
// 消息
noCodesYet: '暂无优惠码',
createFirstCode: '创建您的第一个优惠码,为新用户提供注册奖励。',
codeCreated: '优惠码创建成功',
codeUpdated: '优惠码更新成功',
codeDeleted: '优惠码删除成功',
deleteCodeConfirm: '确定要删除此优惠码吗?此操作无法撤销。',
copyRegisterLink: '复制注册链接',
registerLinkCopied: '注册链接已复制到剪贴板',
failedToLoad: '加载优惠码失败',
failedToCreate: '创建优惠码失败',
failedToUpdate: '更新优惠码失败',
failedToDelete: '删除优惠码失败',
failedToLoadUsages: '加载使用记录失败'
},
// Usage Records
usage: {
title: '使用记录',

View File

@@ -244,6 +244,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.redeem.description'
}
},
{
path: '/admin/promo-codes',
name: 'AdminPromoCodes',
component: () => import('@/views/admin/PromoCodesView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Promo Code Management',
titleKey: 'admin.promo.title',
descriptionKey: 'admin.promo.description'
}
},
{
path: '/admin/settings',
name: 'AdminSettings',

View File

@@ -50,6 +50,7 @@ export interface RegisterRequest {
password: string
verify_code?: string
turnstile_token?: string
promo_code?: string
}
export interface SendVerifyCodeRequest {
@@ -960,3 +961,44 @@ export interface UpdateUserAttributeRequest {
export interface UserAttributeValuesMap {
[attributeId: number]: string
}
// ==================== Promo Code Types ====================
export interface PromoCode {
id: number
code: string
bonus_amount: number
max_uses: number
used_count: number
status: 'active' | 'disabled'
expires_at: string | null
notes: string | null
created_at: string
updated_at: string
}
export interface PromoCodeUsage {
id: number
promo_code_id: number
user_id: number
bonus_amount: number
used_at: string
user?: User
}
export interface CreatePromoCodeRequest {
code?: string
bonus_amount: number
max_uses?: number
expires_at?: number | null
notes?: string
}
export interface UpdatePromoCodeRequest {
code?: string
bonus_amount?: number
max_uses?: number
status?: 'active' | 'disabled'
expires_at?: number | null
notes?: string
}

View File

@@ -0,0 +1,718 @@
<template>
<AppLayout>
<TablePageLayout>
<template #actions>
<div class="flex justify-end gap-3">
<button
@click="loadCodes"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="showCreateDialog = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-1" />
{{ t('admin.promo.createCode') }}
</button>
</div>
</template>
<template #filters>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="max-w-md flex-1">
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.promo.searchCodes')"
class="input"
@input="handleSearch"
/>
</div>
<div class="flex gap-2">
<Select
v-model="filters.status"
:options="filterStatusOptions"
class="w-36"
@change="loadCodes"
/>
</div>
</div>
</template>
<template #table>
<DataTable :columns="columns" :data="codes" :loading="loading">
<template #cell-code="{ value }">
<div class="flex items-center space-x-2">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
<button
@click="copyToClipboard(value)"
:class="[
'flex items-center transition-colors',
copiedCode === value
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
]"
:title="copiedCode === value ? t('admin.promo.copied') : t('keys.copyToClipboard')"
>
<Icon v-if="copiedCode !== value" name="copy" size="sm" :stroke-width="2" />
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</button>
</div>
</template>
<template #cell-bonus_amount="{ value }">
<span class="text-sm font-medium text-gray-900 dark:text-white">
${{ value.toFixed(2) }}
</span>
</template>
<template #cell-usage="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ row.used_count }} / {{ row.max_uses === 0 ? '∞' : row.max_uses }}
</span>
</template>
<template #cell-status="{ value, row }">
<span
:class="[
'badge',
getStatusClass(value, row)
]"
>
{{ getStatusLabel(value, row) }}
</span>
</template>
<template #cell-expires_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? formatDateTime(value) : t('admin.promo.neverExpires') }}
</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ formatDateTime(value) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center space-x-1">
<button
@click="copyRegisterLink(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
:title="t('admin.promo.copyRegisterLink')"
>
<Icon name="link" size="sm" />
</button>
<button
@click="handleViewUsages(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title="t('admin.promo.viewUsages')"
>
<Icon name="eye" size="sm" />
</button>
<button
@click="handleEdit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title="t('common.edit')"
>
<Icon name="edit" size="sm" />
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create Dialog -->
<BaseDialog
:show="showCreateDialog"
:title="t('admin.promo.createCode')"
width="normal"
@close="showCreateDialog = false"
>
<form id="create-promo-form" @submit.prevent="handleCreate" class="space-y-4">
<div>
<label class="input-label">
{{ t('admin.promo.code') }}
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('admin.promo.autoGenerate') }})</span>
</label>
<input
v-model="createForm.code"
type="text"
class="input font-mono uppercase"
:placeholder="t('admin.promo.codePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.promo.bonusAmount') }}</label>
<input
v-model.number="createForm.bonus_amount"
type="number"
step="0.01"
min="0"
required
class="input"
/>
</div>
<div>
<label class="input-label">
{{ t('admin.promo.maxUses') }}
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('admin.promo.zeroUnlimited') }})</span>
</label>
<input
v-model.number="createForm.max_uses"
type="number"
min="0"
class="input"
/>
</div>
<div>
<label class="input-label">
{{ t('admin.promo.expiresAt') }}
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
</label>
<input
v-model="createForm.expires_at_str"
type="datetime-local"
class="input"
/>
</div>
<div>
<label class="input-label">
{{ t('admin.promo.notes') }}
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
</label>
<textarea
v-model="createForm.notes"
rows="2"
class="input"
:placeholder="t('admin.promo.notesPlaceholder')"
></textarea>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" form="create-promo-form" :disabled="creating" class="btn btn-primary">
{{ creating ? t('common.creating') : t('common.create') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
:show="showEditDialog"
:title="t('admin.promo.editCode')"
width="normal"
@close="closeEditDialog"
>
<form id="edit-promo-form" @submit.prevent="handleUpdate" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.promo.code') }}</label>
<input
v-model="editForm.code"
type="text"
class="input font-mono uppercase"
/>
</div>
<div>
<label class="input-label">{{ t('admin.promo.bonusAmount') }}</label>
<input
v-model.number="editForm.bonus_amount"
type="number"
step="0.01"
min="0"
required
class="input"
/>
</div>
<div>
<label class="input-label">
{{ t('admin.promo.maxUses') }}
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('admin.promo.zeroUnlimited') }})</span>
</label>
<input
v-model.number="editForm.max_uses"
type="number"
min="0"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.promo.status') }}</label>
<Select v-model="editForm.status" :options="statusOptions" />
</div>
<div>
<label class="input-label">
{{ t('admin.promo.expiresAt') }}
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
</label>
<input
v-model="editForm.expires_at_str"
type="datetime-local"
class="input"
/>
</div>
<div>
<label class="input-label">
{{ t('admin.promo.notes') }}
<span class="ml-1 text-xs font-normal text-gray-400">({{ t('common.optional') }})</span>
</label>
<textarea
v-model="editForm.notes"
rows="2"
class="input"
></textarea>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="closeEditDialog" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" form="edit-promo-form" :disabled="updating" class="btn btn-primary">
{{ updating ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Usages Dialog -->
<BaseDialog
:show="showUsagesDialog"
:title="t('admin.promo.usageRecords')"
width="wide"
@close="showUsagesDialog = false"
>
<div v-if="usagesLoading" class="flex items-center justify-center py-8">
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
</div>
<div v-else-if="usages.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('admin.promo.noUsages') }}
</div>
<div v-else class="space-y-3">
<div
v-for="usage in usages"
:key="usage.id"
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Icon name="user" size="sm" class="text-green-600 dark:text-green-400" />
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ usage.user?.email || t('admin.promo.userPrefix', { id: usage.user_id }) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ formatDateTime(usage.used_at) }}
</p>
</div>
</div>
<div class="text-right">
<span class="text-sm font-medium text-green-600 dark:text-green-400">
+${{ usage.bonus_amount.toFixed(2) }}
</span>
</div>
</div>
<!-- Usages Pagination -->
<div v-if="usagesTotal > usagesPageSize" class="mt-4">
<Pagination
:page="usagesPage"
:total="usagesTotal"
:page-size="usagesPageSize"
:page-size-options="[10, 20, 50]"
@update:page="handleUsagesPageChange"
@update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button type="button" @click="showUsagesDialog = false" class="btn btn-secondary">
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.promo.deleteCode')"
:message="t('admin.promo.deleteCodeConfirm')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
danger
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { PromoCode, PromoCodeUsage } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
// State
const codes = ref<PromoCode[]>([])
const loading = ref(false)
const creating = ref(false)
const updating = ref(false)
const searchQuery = ref('')
const copiedCode = ref<string | null>(null)
const filters = reactive({
status: ''
})
const pagination = reactive({
page: 1,
page_size: 20,
total: 0
})
// Dialogs
const showCreateDialog = ref(false)
const showEditDialog = ref(false)
const showDeleteDialog = ref(false)
const showUsagesDialog = ref(false)
const editingCode = ref<PromoCode | null>(null)
const deletingCode = ref<PromoCode | null>(null)
// Usages
const usages = ref<PromoCodeUsage[]>([])
const usagesLoading = ref(false)
const currentViewingCode = ref<PromoCode | null>(null)
const usagesPage = ref(1)
const usagesPageSize = ref(20)
const usagesTotal = ref(0)
// Forms
const createForm = reactive({
code: '',
bonus_amount: 1,
max_uses: 0,
expires_at_str: '',
notes: ''
})
const editForm = reactive({
code: '',
bonus_amount: 0,
max_uses: 0,
status: 'active' as 'active' | 'disabled',
expires_at_str: '',
notes: ''
})
// Options
const filterStatusOptions = computed(() => [
{ value: '', label: t('admin.promo.allStatus') },
{ value: 'active', label: t('admin.promo.statusActive') },
{ value: 'disabled', label: t('admin.promo.statusDisabled') }
])
const statusOptions = computed(() => [
{ value: 'active', label: t('admin.promo.statusActive') },
{ value: 'disabled', label: t('admin.promo.statusDisabled') }
])
const columns = computed<Column[]>(() => [
{ key: 'code', label: t('admin.promo.columns.code') },
{ key: 'bonus_amount', label: t('admin.promo.columns.bonusAmount'), sortable: true },
{ key: 'usage', label: t('admin.promo.columns.usage') },
{ key: 'status', label: t('admin.promo.columns.status'), sortable: true },
{ key: 'expires_at', label: t('admin.promo.columns.expiresAt'), sortable: true },
{ key: 'created_at', label: t('admin.promo.columns.createdAt'), sortable: true },
{ key: 'actions', label: t('admin.promo.columns.actions') }
])
// Helpers
const getStatusClass = (status: string, row: PromoCode) => {
if (row.expires_at && new Date(row.expires_at) < new Date()) {
return 'badge-danger'
}
if (row.max_uses > 0 && row.used_count >= row.max_uses) {
return 'badge-gray'
}
return status === 'active' ? 'badge-success' : 'badge-gray'
}
const getStatusLabel = (status: string, row: PromoCode) => {
if (row.expires_at && new Date(row.expires_at) < new Date()) {
return t('admin.promo.statusExpired')
}
if (row.max_uses > 0 && row.used_count >= row.max_uses) {
return t('admin.promo.statusMaxUsed')
}
return status === 'active' ? t('admin.promo.statusActive') : t('admin.promo.statusDisabled')
}
// API calls
let abortController: AbortController | null = null
const loadCodes = async () => {
if (abortController) {
abortController.abort()
}
const currentController = new AbortController()
abortController = currentController
loading.value = true
try {
const response = await adminAPI.promo.list(
pagination.page,
pagination.page_size,
{
status: filters.status || undefined,
search: searchQuery.value || undefined
}
)
if (currentController.signal.aborted) return
codes.value = response.items
pagination.total = response.total
} catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return
appStore.showError(t('admin.promo.failedToLoad'))
console.error('Error loading promo codes:', error)
} finally {
if (abortController === currentController && !currentController.signal.aborted) {
loading.value = false
abortController = null
}
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadCodes()
}, 300)
}
const handlePageChange = (page: number) => {
pagination.page = page
loadCodes()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadCodes()
}
const copyToClipboard = async (text: string) => {
const success = await clipboardCopy(text, t('admin.promo.copied'))
if (success) {
copiedCode.value = text
setTimeout(() => {
copiedCode.value = null
}, 2000)
}
}
// Create
const handleCreate = async () => {
creating.value = true
try {
await adminAPI.promo.create({
code: createForm.code || undefined,
bonus_amount: createForm.bonus_amount,
max_uses: createForm.max_uses,
expires_at: createForm.expires_at_str ? Math.floor(new Date(createForm.expires_at_str).getTime() / 1000) : undefined,
notes: createForm.notes || undefined
})
appStore.showSuccess(t('admin.promo.codeCreated'))
showCreateDialog.value = false
resetCreateForm()
loadCodes()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToCreate'))
} finally {
creating.value = false
}
}
const resetCreateForm = () => {
createForm.code = ''
createForm.bonus_amount = 1
createForm.max_uses = 0
createForm.expires_at_str = ''
createForm.notes = ''
}
// Edit
const handleEdit = (code: PromoCode) => {
editingCode.value = code
editForm.code = code.code
editForm.bonus_amount = code.bonus_amount
editForm.max_uses = code.max_uses
editForm.status = code.status
editForm.expires_at_str = code.expires_at ? new Date(code.expires_at).toISOString().slice(0, 16) : ''
editForm.notes = code.notes || ''
showEditDialog.value = true
}
const closeEditDialog = () => {
showEditDialog.value = false
editingCode.value = null
}
const handleUpdate = async () => {
if (!editingCode.value) return
updating.value = true
try {
await adminAPI.promo.update(editingCode.value.id, {
code: editForm.code,
bonus_amount: editForm.bonus_amount,
max_uses: editForm.max_uses,
status: editForm.status,
expires_at: editForm.expires_at_str ? Math.floor(new Date(editForm.expires_at_str).getTime() / 1000) : 0,
notes: editForm.notes
})
appStore.showSuccess(t('admin.promo.codeUpdated'))
closeEditDialog()
loadCodes()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToUpdate'))
} finally {
updating.value = false
}
}
// Copy Register Link
const copyRegisterLink = async (code: PromoCode) => {
const baseUrl = window.location.origin
const registerLink = `${baseUrl}/register?promo=${encodeURIComponent(code.code)}`
try {
await navigator.clipboard.writeText(registerLink)
appStore.showSuccess(t('admin.promo.registerLinkCopied'))
} catch (error) {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = registerLink
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
appStore.showSuccess(t('admin.promo.registerLinkCopied'))
}
}
// Delete
const handleDelete = (code: PromoCode) => {
deletingCode.value = code
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingCode.value) return
try {
await adminAPI.promo.delete(deletingCode.value.id)
appStore.showSuccess(t('admin.promo.codeDeleted'))
showDeleteDialog.value = false
deletingCode.value = null
loadCodes()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToDelete'))
}
}
// View Usages
const handleViewUsages = async (code: PromoCode) => {
currentViewingCode.value = code
showUsagesDialog.value = true
usagesPage.value = 1
await loadUsages()
}
const loadUsages = async () => {
if (!currentViewingCode.value) return
usagesLoading.value = true
usages.value = []
try {
const response = await adminAPI.promo.getUsages(
currentViewingCode.value.id,
usagesPage.value,
usagesPageSize.value
)
usages.value = response.items
usagesTotal.value = response.total
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.promo.failedToLoadUsages'))
} finally {
usagesLoading.value = false
}
}
const handleUsagesPageChange = (page: number) => {
usagesPage.value = page
loadUsages()
}
onMounted(() => {
loadCodes()
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
})
</script>

View File

@@ -200,6 +200,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
const email = ref<string>('')
const password = ref<string>('')
const initialTurnstileToken = ref<string>('')
const promoCode = ref<string>('')
const hasRegisterData = ref<boolean>(false)
// Public settings
@@ -228,6 +229,7 @@ onMounted(async () => {
email.value = registerData.email || ''
password.value = registerData.password || ''
initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || ''
hasRegisterData.value = !!(email.value && password.value)
} catch {
hasRegisterData.value = false
@@ -381,7 +383,8 @@ async function handleVerify(): Promise<void> {
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined
turnstile_token: initialTurnstileToken.value || undefined,
promo_code: promoCode.value || undefined
})
// Clear session data

View File

@@ -95,6 +95,57 @@
</p>
</div>
<!-- Promo Code Input (Optional) -->
<div>
<label for="promo_code" class="input-label">
{{ t('auth.promoCodeLabel') }}
<span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span>
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="gift" size="md" :class="promoValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
</div>
<input
id="promo_code"
v-model="formData.promo_code"
type="text"
:disabled="isLoading"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': promoValidation.invalid
}"
:placeholder="t('auth.promoCodePlaceholder')"
@input="handlePromoCodeInput"
/>
<!-- Validation indicator -->
<div v-if="promoValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div v-else-if="promoValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="checkCircle" size="md" class="text-green-500" />
</div>
<div v-else-if="promoValidation.invalid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
</div>
<!-- Promo code validation result -->
<transition name="fade">
<div v-if="promoValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
<Icon name="gift" size="sm" class="text-green-600 dark:text-green-400" />
<span class="text-sm text-green-700 dark:text-green-400">
{{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }}
</span>
</div>
<p v-else-if="promoValidation.invalid" class="input-error-text">
{{ promoValidation.message }}
</p>
</transition>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
@@ -180,21 +231,22 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings } from '@/api/auth'
import { getPublicSettings, validatePromoCode } from '@/api/auth'
const { t } = useI18n()
// ==================== Router & Stores ====================
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const appStore = useAppStore()
@@ -217,9 +269,20 @@ const linuxdoOAuthEnabled = ref<boolean>(false)
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
// Promo code validation
const promoValidating = ref<boolean>(false)
const promoValidation = reactive({
valid: false,
invalid: false,
bonusAmount: null as number | null,
message: ''
})
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
const formData = reactive({
email: '',
password: ''
password: '',
promo_code: ''
})
const errors = reactive({
@@ -231,6 +294,14 @@ const errors = reactive({
// ==================== Lifecycle ====================
onMounted(async () => {
// Read promo code from URL parameter
const promoParam = route.query.promo as string
if (promoParam) {
formData.promo_code = promoParam
// Validate the promo code from URL
await validatePromoCodeDebounced(promoParam)
}
try {
const settings = await getPublicSettings()
registrationEnabled.value = settings.registration_enabled
@@ -246,6 +317,85 @@ onMounted(async () => {
}
})
onUnmounted(() => {
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
}
})
// ==================== Promo Code Validation ====================
function handlePromoCodeInput(): void {
const code = formData.promo_code.trim()
// Clear previous validation
promoValidation.valid = false
promoValidation.invalid = false
promoValidation.bonusAmount = null
promoValidation.message = ''
if (!code) {
promoValidating.value = false
return
}
// Debounce validation
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
}
promoValidateTimeout = setTimeout(() => {
validatePromoCodeDebounced(code)
}, 500)
}
async function validatePromoCodeDebounced(code: string): Promise<void> {
if (!code.trim()) return
promoValidating.value = true
try {
const result = await validatePromoCode(code)
if (result.valid) {
promoValidation.valid = true
promoValidation.invalid = false
promoValidation.bonusAmount = result.bonus_amount || 0
promoValidation.message = ''
} else {
promoValidation.valid = false
promoValidation.invalid = true
promoValidation.bonusAmount = null
// 根据错误码显示对应的翻译
promoValidation.message = getPromoErrorMessage(result.error_code)
}
} catch (error) {
console.error('Failed to validate promo code:', error)
promoValidation.valid = false
promoValidation.invalid = true
promoValidation.message = t('auth.promoCodeInvalid')
} finally {
promoValidating.value = false
}
}
function getPromoErrorMessage(errorCode?: string): string {
switch (errorCode) {
case 'PROMO_CODE_NOT_FOUND':
return t('auth.promoCodeNotFound')
case 'PROMO_CODE_EXPIRED':
return t('auth.promoCodeExpired')
case 'PROMO_CODE_DISABLED':
return t('auth.promoCodeDisabled')
case 'PROMO_CODE_MAX_USED':
return t('auth.promoCodeMaxUsed')
case 'PROMO_CODE_ALREADY_USED':
return t('auth.promoCodeAlreadyUsed')
default:
return t('auth.promoCodeInvalid')
}
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
@@ -316,6 +466,20 @@ async function handleRegister(): Promise<void> {
return
}
// Check promo code validation status
if (formData.promo_code.trim()) {
// If promo code is being validated, wait
if (promoValidating.value) {
errorMessage.value = t('auth.promoCodeValidating')
return
}
// If promo code is invalid, block submission
if (promoValidation.invalid) {
errorMessage.value = t('auth.promoCodeInvalidCannotRegister')
return
}
}
isLoading.value = true
try {
@@ -327,7 +491,8 @@ async function handleRegister(): Promise<void> {
JSON.stringify({
email: formData.email,
password: formData.password,
turnstile_token: turnstileToken.value
turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined
})
)
@@ -340,7 +505,8 @@ async function handleRegister(): Promise<void> {
await authStore.register({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined
})
// Show success toast