merge: 正确合并 main 分支改动
合并 origin/main 最新改动,正确保留所有配置: - Ops 运维监控配置和功能 - LinuxDo Connect OAuth 配置 - Update 在线更新配置 - 优惠码功能 - 其他 main 分支新功能 修复之前合并时错误删除 LinuxDo 和 Update 配置的问题。
This commit is contained in:
@@ -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'
|
||||
@@ -28,6 +29,7 @@ export const adminAPI = {
|
||||
accounts: accountsAPI,
|
||||
proxies: proxiesAPI,
|
||||
redeem: redeemAPI,
|
||||
promo: promoAPI,
|
||||
settings: settingsAPI,
|
||||
system: systemAPI,
|
||||
subscriptions: subscriptionsAPI,
|
||||
@@ -45,6 +47,7 @@ export {
|
||||
accountsAPI,
|
||||
proxiesAPI,
|
||||
redeemAPI,
|
||||
promoAPI,
|
||||
settingsAPI,
|
||||
systemAPI,
|
||||
subscriptionsAPI,
|
||||
|
||||
69
frontend/src/api/admin/promo.ts
Normal file
69
frontend/src/api/admin/promo.ts
Normal 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
|
||||
@@ -22,6 +22,7 @@ export interface SystemSettings {
|
||||
api_base_url: string
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
// SMTP settings
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
@@ -64,6 +65,7 @@ export interface UpdateSettingsRequest {
|
||||
api_base_url?: string
|
||||
contact_info?: string
|
||||
doc_url?: string
|
||||
home_content?: string
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_username?: string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -452,6 +452,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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -148,8 +148,9 @@ export default {
|
||||
contactSupport: 'Contact Support',
|
||||
add: 'Add',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
selectOption: 'Select an option',
|
||||
searchPlaceholder: 'Search...',
|
||||
optional: 'optional',
|
||||
selectOption: 'Select an option',
|
||||
searchPlaceholder: 'Search...',
|
||||
noOptionsFound: 'No options found',
|
||||
noGroupsAvailable: 'No groups available',
|
||||
unknownError: 'Unknown error occurred',
|
||||
@@ -181,6 +182,7 @@ export default {
|
||||
proxies: 'Proxies',
|
||||
redeemCodes: 'Redeem Codes',
|
||||
ops: 'Ops',
|
||||
promoCodes: 'Promo Codes',
|
||||
settings: 'Settings',
|
||||
myAccount: 'My Account',
|
||||
lightMode: 'Light Mode',
|
||||
@@ -233,6 +235,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',
|
||||
@@ -1726,6 +1739,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',
|
||||
@@ -2147,6 +2219,7 @@ export default {
|
||||
loadFailed: 'Failed to load concurrency data'
|
||||
},
|
||||
realtime: {
|
||||
title: 'Realtime',
|
||||
connected: 'Realtime connected',
|
||||
connecting: 'Realtime connecting',
|
||||
reconnecting: 'Realtime reconnecting',
|
||||
@@ -2270,7 +2343,11 @@ export default {
|
||||
logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.',
|
||||
logoSizeError: 'Image size exceeds 300KB limit ({size}KB)',
|
||||
logoTypeError: 'Please select an image file',
|
||||
logoReadError: 'Failed to read the image file'
|
||||
logoReadError: 'Failed to read the image file',
|
||||
homeContent: 'Home Page Content',
|
||||
homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.',
|
||||
homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.',
|
||||
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
|
||||
@@ -145,6 +145,7 @@ export default {
|
||||
contactSupport: '联系客服',
|
||||
add: '添加',
|
||||
invalidEmail: '请输入有效的邮箱地址',
|
||||
optional: '可选',
|
||||
selectOption: '请选择',
|
||||
searchPlaceholder: '搜索...',
|
||||
noOptionsFound: '无匹配选项',
|
||||
@@ -179,6 +180,7 @@ export default {
|
||||
proxies: 'IP管理',
|
||||
redeemCodes: '兑换码',
|
||||
ops: '运维监控',
|
||||
promoCodes: '优惠码',
|
||||
settings: '系统设置',
|
||||
myAccount: '我的账户',
|
||||
lightMode: '浅色模式',
|
||||
@@ -231,6 +233,17 @@ export default {
|
||||
sendingCode: '发送中...',
|
||||
clickToResend: '点击重新发送验证码',
|
||||
resendCode: '重新发送验证码',
|
||||
promoCodeLabel: '优惠码',
|
||||
promoCodePlaceholder: '输入优惠码(可选)',
|
||||
promoCodeValid: '有效!注册后将获得 ${amount} 赠送余额',
|
||||
promoCodeInvalid: '无效的优惠码',
|
||||
promoCodeNotFound: '优惠码不存在',
|
||||
promoCodeExpired: '此优惠码已过期',
|
||||
promoCodeDisabled: '此优惠码已被禁用',
|
||||
promoCodeMaxUsed: '此优惠码已达到使用上限',
|
||||
promoCodeAlreadyUsed: '您已使用过此优惠码',
|
||||
promoCodeValidating: '优惠码正在验证中,请稍候',
|
||||
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
|
||||
linuxdo: {
|
||||
signIn: '使用 Linux.do 登录',
|
||||
orContinue: '或使用邮箱密码继续',
|
||||
@@ -1871,6 +1884,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: '使用记录',
|
||||
@@ -2292,6 +2364,7 @@ export default {
|
||||
loadFailed: '加载并发数据失败'
|
||||
},
|
||||
realtime: {
|
||||
title: '实时信息',
|
||||
connected: '实时已连接',
|
||||
connecting: '实时连接中',
|
||||
reconnecting: '实时重连中',
|
||||
@@ -2413,7 +2486,11 @@ export default {
|
||||
logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。',
|
||||
logoSizeError: '图片大小超过 300KB 限制({size}KB)',
|
||||
logoTypeError: '请选择图片文件',
|
||||
logoReadError: '读取图片文件失败'
|
||||
logoReadError: '读取图片文件失败',
|
||||
homeContent: '首页内容',
|
||||
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
|
||||
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
|
||||
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
|
||||
@@ -6,7 +6,20 @@ import i18n from './i18n'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
import { useAppStore } from '@/stores/app'
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
/**
|
||||
* Route definitions with lazy loading
|
||||
@@ -256,6 +257,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',
|
||||
@@ -323,10 +336,12 @@ router.beforeEach((to, _from, next) => {
|
||||
}
|
||||
|
||||
// Set page title
|
||||
const appStore = useAppStore()
|
||||
const siteName = appStore.siteName || 'Sub2API'
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - Sub2API`
|
||||
document.title = `${to.meta.title} - ${siteName}`
|
||||
} else {
|
||||
document.title = 'Sub2API'
|
||||
document.title = siteName
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
|
||||
@@ -279,11 +279,31 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
// ==================== Public Settings Management ====================
|
||||
|
||||
/**
|
||||
* Apply settings to store state (internal helper to avoid code duplication)
|
||||
*/
|
||||
function applySettings(config: PublicSettings): void {
|
||||
cachedPublicSettings.value = config
|
||||
siteName.value = config.site_name || 'Sub2API'
|
||||
siteLogo.value = config.site_logo || ''
|
||||
siteVersion.value = config.version || ''
|
||||
contactInfo.value = config.contact_info || ''
|
||||
apiBaseUrl.value = config.api_base_url || ''
|
||||
docUrl.value = config.doc_url || ''
|
||||
publicSettingsLoaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch public settings (uses cache unless force=true)
|
||||
* @param force - Force refresh from API
|
||||
*/
|
||||
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
||||
// Check for injected config from server (eliminates flash)
|
||||
if (!publicSettingsLoaded.value && !force && window.__APP_CONFIG__) {
|
||||
applySettings(window.__APP_CONFIG__)
|
||||
return window.__APP_CONFIG__
|
||||
}
|
||||
|
||||
// Return cached data if available and not forcing refresh
|
||||
if (publicSettingsLoaded.value && !force) {
|
||||
if (cachedPublicSettings.value) {
|
||||
@@ -300,6 +320,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
api_base_url: apiBaseUrl.value,
|
||||
contact_info: contactInfo.value,
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
linuxdo_oauth_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
@@ -313,14 +334,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
publicSettingsLoading.value = true
|
||||
try {
|
||||
const data = await fetchPublicSettingsAPI()
|
||||
cachedPublicSettings.value = data
|
||||
siteName.value = data.site_name || 'Sub2API'
|
||||
siteLogo.value = data.site_logo || ''
|
||||
siteVersion.value = data.version || ''
|
||||
contactInfo.value = data.contact_info || ''
|
||||
apiBaseUrl.value = data.api_base_url || ''
|
||||
docUrl.value = data.doc_url || ''
|
||||
publicSettingsLoaded.value = true
|
||||
applySettings(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch public settings:', error)
|
||||
@@ -338,6 +352,19 @@ export const useAppStore = defineStore('app', () => {
|
||||
cachedPublicSettings.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize settings from injected config (window.__APP_CONFIG__)
|
||||
* This is called synchronously before Vue app mounts to prevent flash
|
||||
* @returns true if config was found and applied, false otherwise
|
||||
*/
|
||||
function initFromInjectedConfig(): boolean {
|
||||
if (window.__APP_CONFIG__) {
|
||||
applySettings(window.__APP_CONFIG__)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ==================== Return Store API ====================
|
||||
|
||||
return {
|
||||
@@ -355,6 +382,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
contactInfo,
|
||||
apiBaseUrl,
|
||||
docUrl,
|
||||
cachedPublicSettings,
|
||||
|
||||
// Version state
|
||||
versionLoaded,
|
||||
@@ -391,6 +419,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
// Public settings actions
|
||||
fetchPublicSettings,
|
||||
clearPublicSettingsCache
|
||||
clearPublicSettingsCache,
|
||||
initFromInjectedConfig
|
||||
}
|
||||
})
|
||||
|
||||
9
frontend/src/types/global.d.ts
vendored
Normal file
9
frontend/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { PublicSettings } from '@/types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: PublicSettings
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -50,6 +50,7 @@ export interface RegisterRequest {
|
||||
password: string
|
||||
verify_code?: string
|
||||
turnstile_token?: string
|
||||
promo_code?: string
|
||||
}
|
||||
|
||||
export interface SendVerifyCodeRequest {
|
||||
@@ -73,6 +74,7 @@ export interface PublicSettings {
|
||||
api_base_url: string
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
linuxdo_oauth_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
@@ -960,3 +962,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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<template>
|
||||
<!-- Custom Home Content: Full Page Mode -->
|
||||
<div v-if="homeContent" class="min-h-screen">
|
||||
<!-- iframe mode -->
|
||||
<iframe
|
||||
v-if="isHomeContentUrl"
|
||||
:src="homeContent.trim()"
|
||||
class="h-screen w-full border-0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<!-- HTML mode - SECURITY: homeContent is admin-only setting, XSS risk is acceptable -->
|
||||
<div v-else v-html="homeContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Default Home Page -->
|
||||
<div
|
||||
class="relative min-h-screen overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
||||
v-else
|
||||
class="relative flex min-h-screen flex-col overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
||||
>
|
||||
<!-- Background Decorations -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
@@ -96,7 +111,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="relative z-10 px-6 py-16">
|
||||
<main class="relative z-10 flex-1 px-6 py-16">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<!-- Hero Section - Left/Right Layout -->
|
||||
<div class="mb-12 flex flex-col items-center justify-between gap-12 lg:flex-row lg:gap-16">
|
||||
@@ -392,21 +407,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Site settings
|
||||
const siteName = ref('Sub2API')
|
||||
const siteLogo = ref('')
|
||||
const siteSubtitle = ref('AI API Gateway Platform')
|
||||
const docUrl = ref('')
|
||||
// Site settings - directly from appStore (already initialized from injected config)
|
||||
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
|
||||
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
|
||||
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
|
||||
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
|
||||
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
|
||||
|
||||
// Check if homeContent is a URL (for iframe display)
|
||||
const isHomeContentUrl = computed(() => {
|
||||
const content = homeContent.value.trim()
|
||||
return content.startsWith('http://') || content.startsWith('https://')
|
||||
})
|
||||
|
||||
// Theme
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
@@ -446,20 +467,15 @@ function initTheme() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
|
||||
// Check auth state
|
||||
authStore.checkAuth()
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
||||
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform'
|
||||
docUrl.value = sanitizeUrl(settings.doc_url || '', { allowRelative: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
// Ensure public settings are loaded (will use cache if already loaded from injected config)
|
||||
if (!appStore.publicSettingsLoaded) {
|
||||
appStore.fetchPublicSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
718
frontend/src/views/admin/PromoCodesView.vue
Normal file
718
frontend/src/views/admin/PromoCodesView.vue
Normal 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>
|
||||
@@ -462,6 +462,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Home Content -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.homeContent') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.home_content"
|
||||
rows="6"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.site.homeContentPlaceholder')"
|
||||
></textarea>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.homeContentHint') }}
|
||||
</p>
|
||||
<!-- iframe CSP Warning -->
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.settings.site.homeContentIframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -818,6 +838,7 @@ const form = reactive<SettingsForm>({
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -923,6 +944,7 @@ async function saveSettings() {
|
||||
api_base_url: form.api_base_url,
|
||||
contact_info: form.contact_info,
|
||||
doc_url: form.doc_url,
|
||||
home_content: form.home_content,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
|
||||
@@ -209,14 +209,12 @@ const durationP95Ms = computed(() => overview.value?.duration?.p95_ms ?? null)
|
||||
const durationP90Ms = computed(() => overview.value?.duration?.p90_ms ?? null)
|
||||
const durationP50Ms = computed(() => overview.value?.duration?.p50_ms ?? null)
|
||||
const durationAvgMs = computed(() => overview.value?.duration?.avg_ms ?? null)
|
||||
const durationMaxMs = computed(() => overview.value?.duration?.max_ms ?? null)
|
||||
|
||||
const ttftP99Ms = computed(() => overview.value?.ttft?.p99_ms ?? null)
|
||||
const ttftP95Ms = computed(() => overview.value?.ttft?.p95_ms ?? null)
|
||||
const ttftP90Ms = computed(() => overview.value?.ttft?.p90_ms ?? null)
|
||||
const ttftP50Ms = computed(() => overview.value?.ttft?.p50_ms ?? null)
|
||||
const ttftAvgMs = computed(() => overview.value?.ttft?.avg_ms ?? null)
|
||||
const ttftMaxMs = computed(() => overview.value?.ttft?.max_ms ?? null)
|
||||
|
||||
// --- WebSocket status ---
|
||||
|
||||
@@ -708,7 +706,7 @@ function openJobsDetails() {
|
||||
</div>
|
||||
|
||||
<div v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<!-- Left: Health + Realtime -->\
|
||||
<!-- Left: Health + Realtime -->
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900 lg:col-span-5">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
|
||||
<!-- 1) Health Score -->
|
||||
@@ -812,7 +810,7 @@ function openJobsDetails() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2) Realtime Traffic -->\
|
||||
<!-- 2) Realtime Traffic -->
|
||||
<div class="flex flex-col justify-center py-2">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -976,7 +974,7 @@ function openJobsDetails() {
|
||||
</div>
|
||||
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs">
|
||||
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">P95:</span>
|
||||
<span class="font-bold" :class="getLatencyColor(durationP95Ms)">{{ durationP95Ms ?? '-' }}ms</span>
|
||||
@@ -993,10 +991,6 @@ function openJobsDetails() {
|
||||
<span class="text-gray-500">Avg:</span>
|
||||
<span class="font-bold" :class="getLatencyColor(durationAvgMs)">{{ durationAvgMs ?? '-' }}ms</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Max:</span>
|
||||
<span class="font-bold" :class="getLatencyColor(durationMaxMs)">{{ durationMaxMs ?? '-' }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1021,7 +1015,7 @@ function openJobsDetails() {
|
||||
</div>
|
||||
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs">
|
||||
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">P95:</span>
|
||||
<span class="font-bold" :class="getLatencyColor(ttftP95Ms)">{{ ttftP95Ms ?? '-' }}ms</span>
|
||||
@@ -1038,10 +1032,6 @@ function openJobsDetails() {
|
||||
<span class="text-gray-500">Avg:</span>
|
||||
<span class="font-bold" :class="getLatencyColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}ms</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Max:</span>
|
||||
<span class="font-bold" :class="getLatencyColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user