mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
Merge branch 'main' of github.com:Wei-Shaw/sub2api into qingyu/fix-smooth-sidebar-collapse
# Conflicts: # frontend/src/components/layout/AppSidebar.vue
This commit is contained in:
@@ -16,9 +16,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobehub/icons": "^4.0.2",
|
||||
"@stripe/stripe-js": "^9.0.1",
|
||||
"@tanstack/vue-virtual": "^3.13.23",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.15.0",
|
||||
"chart.js": "^4.4.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"driver.js": "^1.4.0",
|
||||
|
||||
1024
frontend/pnpm-lock.yaml
generated
1024
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,8 @@ export async function list(
|
||||
search?: string
|
||||
privacy_mode?: string
|
||||
lite?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -71,6 +73,8 @@ export async function listWithEtag(
|
||||
search?: string
|
||||
privacy_mode?: string
|
||||
lite?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -500,7 +504,11 @@ export async function exportData(options?: {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
group?: string
|
||||
privacy_mode?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
includeProxies?: boolean
|
||||
}): Promise<AdminDataPayload> {
|
||||
@@ -508,11 +516,15 @@ export async function exportData(options?: {
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { platform, type, status, search } = options.filters
|
||||
const { platform, type, status, group, privacy_mode, search, sort_by, sort_order } = options.filters
|
||||
if (platform) params.platform = platform
|
||||
if (type) params.type = type
|
||||
if (status) params.status = status
|
||||
if (group) params.group = group
|
||||
if (privacy_mode) params.privacy_mode = privacy_mode
|
||||
if (search) params.search = search
|
||||
if (sort_by) params.sort_by = sort_by
|
||||
if (sort_order) params.sort_order = sort_order
|
||||
}
|
||||
if (options?.includeProxies === false) {
|
||||
params.include_proxies = 'false'
|
||||
|
||||
@@ -17,10 +17,16 @@ export async function list(
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<Announcement>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
|
||||
params: { page, page_size: pageSize, ...filters }
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -49,11 +55,21 @@ export async function getReadStatus(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
search: string = ''
|
||||
filters?: {
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
|
||||
`/admin/announcements/${id}/read-status`,
|
||||
{ params: { page, page_size: pageSize, search } }
|
||||
{
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
@@ -68,4 +84,3 @@ const announcementsAPI = {
|
||||
}
|
||||
|
||||
export default announcementsAPI
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ export async function list(
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<Channel>> {
|
||||
|
||||
@@ -27,6 +27,8 @@ export async function list(
|
||||
status?: 'active' | 'inactive'
|
||||
is_exclusive?: boolean
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
|
||||
@@ -26,6 +26,7 @@ import scheduledTestsAPI from './scheduledTests'
|
||||
import backupAPI from './backup'
|
||||
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
||||
import channelsAPI from './channels'
|
||||
import adminPaymentAPI from './payment'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -53,7 +54,8 @@ export const adminAPI = {
|
||||
scheduledTests: scheduledTestsAPI,
|
||||
backup: backupAPI,
|
||||
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
|
||||
channels: channelsAPI
|
||||
channels: channelsAPI,
|
||||
payment: adminPaymentAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -79,7 +81,8 @@ export {
|
||||
scheduledTestsAPI,
|
||||
backupAPI,
|
||||
tlsFingerprintProfileAPI,
|
||||
channelsAPI
|
||||
channelsAPI,
|
||||
adminPaymentAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
176
frontend/src/api/admin/payment.ts
Normal file
176
frontend/src/api/admin/payment.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Admin Payment API endpoints
|
||||
* Handles payment management operations for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
DashboardStats,
|
||||
PaymentOrder,
|
||||
PaymentChannel,
|
||||
SubscriptionPlan,
|
||||
ProviderInstance
|
||||
} from '@/types/payment'
|
||||
import type { BasePaginationResponse } from '@/types'
|
||||
|
||||
/** Admin-facing payment config returned by GET /admin/payment/config */
|
||||
export interface AdminPaymentConfig {
|
||||
enabled: boolean
|
||||
min_amount: number
|
||||
max_amount: number
|
||||
daily_limit: number
|
||||
order_timeout_minutes: number
|
||||
max_pending_orders: number
|
||||
enabled_payment_types: string[]
|
||||
balance_disabled: boolean
|
||||
load_balance_strategy: string
|
||||
product_name_prefix: string
|
||||
product_name_suffix: string
|
||||
help_image_url: string
|
||||
help_text: string
|
||||
}
|
||||
|
||||
/** Fields accepted by PUT /admin/payment/config (all optional via pointer semantics) */
|
||||
export interface UpdatePaymentConfigRequest {
|
||||
enabled?: boolean
|
||||
min_amount?: number
|
||||
max_amount?: number
|
||||
daily_limit?: number
|
||||
order_timeout_minutes?: number
|
||||
max_pending_orders?: number
|
||||
enabled_payment_types?: string[]
|
||||
balance_disabled?: boolean
|
||||
load_balance_strategy?: string
|
||||
product_name_prefix?: string
|
||||
product_name_suffix?: string
|
||||
help_image_url?: string
|
||||
help_text?: string
|
||||
}
|
||||
|
||||
export const adminPaymentAPI = {
|
||||
// ==================== Config ====================
|
||||
|
||||
/** Get payment configuration (admin view) */
|
||||
getConfig() {
|
||||
return apiClient.get<AdminPaymentConfig>('/admin/payment/config')
|
||||
},
|
||||
|
||||
/** Update payment configuration */
|
||||
updateConfig(data: UpdatePaymentConfigRequest) {
|
||||
return apiClient.put('/admin/payment/config', data)
|
||||
},
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
|
||||
/** Get payment dashboard statistics */
|
||||
getDashboard(days?: number) {
|
||||
return apiClient.get<DashboardStats>('/admin/payment/dashboard', {
|
||||
params: days ? { days } : undefined
|
||||
})
|
||||
},
|
||||
|
||||
// ==================== Orders ====================
|
||||
|
||||
/** Get all orders (paginated, with filters) */
|
||||
getOrders(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
payment_type?: string
|
||||
user_id?: number
|
||||
keyword?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
order_type?: string
|
||||
}) {
|
||||
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/admin/payment/orders', { params })
|
||||
},
|
||||
|
||||
/** Get a specific order by ID */
|
||||
getOrder(id: number) {
|
||||
return apiClient.get<PaymentOrder>(`/admin/payment/orders/${id}`)
|
||||
},
|
||||
|
||||
/** Cancel an order (admin) */
|
||||
cancelOrder(id: number) {
|
||||
return apiClient.post(`/admin/payment/orders/${id}/cancel`)
|
||||
},
|
||||
|
||||
/** Retry recharge for a failed order */
|
||||
retryRecharge(id: number) {
|
||||
return apiClient.post(`/admin/payment/orders/${id}/retry`)
|
||||
},
|
||||
|
||||
/** Process a refund */
|
||||
refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean; force?: boolean }) {
|
||||
return apiClient.post(`/admin/payment/orders/${id}/refund`, data)
|
||||
},
|
||||
|
||||
// ==================== Channels ====================
|
||||
|
||||
/** Get all payment channels */
|
||||
getChannels() {
|
||||
return apiClient.get<PaymentChannel[]>('/admin/payment/channels')
|
||||
},
|
||||
|
||||
/** Create a payment channel */
|
||||
createChannel(data: Partial<PaymentChannel>) {
|
||||
return apiClient.post<PaymentChannel>('/admin/payment/channels', data)
|
||||
},
|
||||
|
||||
/** Update a payment channel */
|
||||
updateChannel(id: number, data: Partial<PaymentChannel>) {
|
||||
return apiClient.put<PaymentChannel>(`/admin/payment/channels/${id}`, data)
|
||||
},
|
||||
|
||||
/** Delete a payment channel */
|
||||
deleteChannel(id: number) {
|
||||
return apiClient.delete(`/admin/payment/channels/${id}`)
|
||||
},
|
||||
|
||||
// ==================== Subscription Plans ====================
|
||||
|
||||
/** Get all subscription plans */
|
||||
getPlans() {
|
||||
return apiClient.get<SubscriptionPlan[]>('/admin/payment/plans')
|
||||
},
|
||||
|
||||
/** Create a subscription plan */
|
||||
createPlan(data: Record<string, unknown>) {
|
||||
return apiClient.post<SubscriptionPlan>('/admin/payment/plans', data)
|
||||
},
|
||||
|
||||
/** Update a subscription plan */
|
||||
updatePlan(id: number, data: Record<string, unknown>) {
|
||||
return apiClient.put<SubscriptionPlan>(`/admin/payment/plans/${id}`, data)
|
||||
},
|
||||
|
||||
/** Delete a subscription plan */
|
||||
deletePlan(id: number) {
|
||||
return apiClient.delete(`/admin/payment/plans/${id}`)
|
||||
},
|
||||
|
||||
// ==================== Provider Instances ====================
|
||||
|
||||
/** Get all provider instances */
|
||||
getProviders() {
|
||||
return apiClient.get<ProviderInstance[]>('/admin/payment/providers')
|
||||
},
|
||||
|
||||
/** Create a provider instance */
|
||||
createProvider(data: Partial<ProviderInstance>) {
|
||||
return apiClient.post<ProviderInstance>('/admin/payment/providers', data)
|
||||
},
|
||||
|
||||
/** Update a provider instance */
|
||||
updateProvider(id: number, data: Partial<ProviderInstance>) {
|
||||
return apiClient.put<ProviderInstance>(`/admin/payment/providers/${id}`, data)
|
||||
},
|
||||
|
||||
/** Delete a provider instance */
|
||||
deleteProvider(id: number) {
|
||||
return apiClient.delete(`/admin/payment/providers/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default adminPaymentAPI
|
||||
@@ -17,10 +17,16 @@ export async function list(
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<PromoCode>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
|
||||
params: { page, page_size: pageSize, ...filters }
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export async function list(
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -227,16 +229,20 @@ export async function exportData(options?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
}): Promise<AdminDataPayload> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { protocol, status, search } = options.filters
|
||||
const { protocol, status, search, sort_by, sort_order } = options.filters
|
||||
if (protocol) params.protocol = protocol
|
||||
if (status) params.status = status
|
||||
if (search) params.search = search
|
||||
if (sort_by) params.sort_by = sort_by
|
||||
if (sort_order) params.sort_order = sort_order
|
||||
}
|
||||
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
|
||||
return data
|
||||
|
||||
@@ -25,6 +25,8 @@ export async function list(
|
||||
type?: RedeemCodeType
|
||||
status?: 'active' | 'used' | 'expired' | 'unused'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -151,7 +153,10 @@ export async function getStats(): Promise<{
|
||||
*/
|
||||
export async function exportCodes(filters?: {
|
||||
type?: RedeemCodeType
|
||||
status?: 'active' | 'used' | 'expired'
|
||||
status?: 'used' | 'expired' | 'unused'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}): Promise<Blob> {
|
||||
const response = await apiClient.get('/admin/redeem-codes/export', {
|
||||
params: filters,
|
||||
|
||||
@@ -38,8 +38,8 @@ export interface SystemSettings {
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
table_default_page_size: number
|
||||
table_page_size_options: number[]
|
||||
backend_mode_enabled: boolean
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
@@ -114,6 +114,26 @@ export interface SystemSettings {
|
||||
enable_fingerprint_unification: boolean
|
||||
enable_metadata_passthrough: boolean
|
||||
enable_cch_signing: boolean
|
||||
|
||||
// Payment configuration
|
||||
payment_enabled: boolean
|
||||
payment_min_amount: number
|
||||
payment_max_amount: number
|
||||
payment_daily_limit: number
|
||||
payment_order_timeout_minutes: number
|
||||
payment_max_pending_orders: number
|
||||
payment_enabled_types: string[]
|
||||
payment_balance_disabled: boolean
|
||||
payment_load_balance_strategy: string
|
||||
payment_product_name_prefix: string
|
||||
payment_product_name_suffix: string
|
||||
payment_help_image_url: string
|
||||
payment_help_text: string
|
||||
payment_cancel_rate_limit_enabled: boolean
|
||||
payment_cancel_rate_limit_max: number
|
||||
payment_cancel_rate_limit_window: number
|
||||
payment_cancel_rate_limit_unit: string
|
||||
payment_cancel_rate_limit_window_mode: string
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@@ -136,8 +156,8 @@ export interface UpdateSettingsRequest {
|
||||
doc_url?: string
|
||||
home_content?: string
|
||||
hide_ccs_import_button?: boolean
|
||||
purchase_subscription_enabled?: boolean
|
||||
purchase_subscription_url?: string
|
||||
table_default_page_size?: number
|
||||
table_page_size_options?: number[]
|
||||
backend_mode_enabled?: boolean
|
||||
custom_menu_items?: CustomMenuItem[]
|
||||
custom_endpoints?: CustomEndpoint[]
|
||||
@@ -194,6 +214,25 @@ export interface UpdateSettingsRequest {
|
||||
enable_fingerprint_unification?: boolean
|
||||
enable_metadata_passthrough?: boolean
|
||||
enable_cch_signing?: boolean
|
||||
// Payment configuration
|
||||
payment_enabled?: boolean
|
||||
payment_min_amount?: number
|
||||
payment_max_amount?: number
|
||||
payment_daily_limit?: number
|
||||
payment_order_timeout_minutes?: number
|
||||
payment_max_pending_orders?: number
|
||||
payment_enabled_types?: string[]
|
||||
payment_balance_disabled?: boolean
|
||||
payment_load_balance_strategy?: string
|
||||
payment_product_name_prefix?: string
|
||||
payment_product_name_suffix?: string
|
||||
payment_help_image_url?: string
|
||||
payment_help_text?: string
|
||||
payment_cancel_rate_limit_enabled?: boolean
|
||||
payment_cancel_rate_limit_max?: number
|
||||
payment_cancel_rate_limit_window?: number
|
||||
payment_cancel_rate_limit_unit?: string
|
||||
payment_cancel_rate_limit_window_mode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number
|
||||
exact_total?: boolean
|
||||
billing_mode?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
@@ -24,6 +24,8 @@ export async function list(
|
||||
group_name?: string // fuzzy filter by allowed group name
|
||||
attributes?: Record<number, string> // attributeId -> value
|
||||
include_subscriptions?: boolean
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -37,7 +39,9 @@ export async function list(
|
||||
role: filters?.role,
|
||||
search: filters?.search,
|
||||
group_name: filters?.group_name,
|
||||
include_subscriptions: filters?.include_subscriptions
|
||||
include_subscriptions: filters?.include_subscriptions,
|
||||
sort_by: filters?.sort_by,
|
||||
sort_order: filters?.sort_order
|
||||
}
|
||||
|
||||
// Add attribute filters as attr[id]=value
|
||||
|
||||
@@ -92,10 +92,13 @@ apiClient.interceptors.response.use(
|
||||
response.data = apiResponse.data
|
||||
} else {
|
||||
// API error
|
||||
const resp = apiResponse as Record<string, unknown>
|
||||
return Promise.reject({
|
||||
status: response.status,
|
||||
code: apiResponse.code,
|
||||
message: apiResponse.message || 'Unknown error'
|
||||
message: apiResponse.message || 'Unknown error',
|
||||
reason: resp.reason,
|
||||
metadata: resp.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -268,7 +271,9 @@ apiClient.interceptors.response.use(
|
||||
status,
|
||||
code: apiData.code,
|
||||
error: apiData.error,
|
||||
message: apiData.message || apiData.detail || error.message
|
||||
message: apiData.message || apiData.detail || error.message,
|
||||
reason: apiData.reason,
|
||||
metadata: apiData.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export { keysAPI } from './keys'
|
||||
export { usageAPI } from './usage'
|
||||
export { userAPI } from './user'
|
||||
export { redeemAPI, type RedeemHistoryItem } from './redeem'
|
||||
export { paymentAPI } from './payment'
|
||||
export { userGroupsAPI } from './groups'
|
||||
export { totpAPI } from './totp'
|
||||
export { default as announcementsAPI } from './announcements'
|
||||
|
||||
@@ -17,7 +17,13 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filters?: { search?: string; status?: string; group_id?: number | string },
|
||||
filters?: {
|
||||
search?: string
|
||||
status?: string
|
||||
group_id?: number | string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
79
frontend/src/api/payment.ts
Normal file
79
frontend/src/api/payment.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* User Payment API endpoints
|
||||
* Handles payment operations for regular users
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
PaymentConfig,
|
||||
SubscriptionPlan,
|
||||
PaymentChannel,
|
||||
MethodLimitsResponse,
|
||||
CheckoutInfoResponse,
|
||||
CreateOrderRequest,
|
||||
CreateOrderResult,
|
||||
PaymentOrder
|
||||
} from '@/types/payment'
|
||||
import type { BasePaginationResponse } from '@/types'
|
||||
|
||||
export const paymentAPI = {
|
||||
/** Get payment configuration (enabled types, limits, etc.) */
|
||||
getConfig() {
|
||||
return apiClient.get<PaymentConfig>('/payment/config')
|
||||
},
|
||||
|
||||
/** Get available subscription plans */
|
||||
getPlans() {
|
||||
return apiClient.get<SubscriptionPlan[]>('/payment/plans')
|
||||
},
|
||||
|
||||
/** Get available payment channels */
|
||||
getChannels() {
|
||||
return apiClient.get<PaymentChannel[]>('/payment/channels')
|
||||
},
|
||||
|
||||
/** Get all checkout page data in a single call */
|
||||
getCheckoutInfo() {
|
||||
return apiClient.get<CheckoutInfoResponse>('/payment/checkout-info')
|
||||
},
|
||||
|
||||
/** Get payment method limits and fee rates */
|
||||
getLimits() {
|
||||
return apiClient.get<MethodLimitsResponse>('/payment/limits')
|
||||
},
|
||||
|
||||
/** Create a new payment order */
|
||||
createOrder(data: CreateOrderRequest) {
|
||||
return apiClient.post<CreateOrderResult>('/payment/orders', data)
|
||||
},
|
||||
|
||||
/** Get current user's orders */
|
||||
getMyOrders(params?: { page?: number; page_size?: number; status?: string }) {
|
||||
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/payment/orders/my', { params })
|
||||
},
|
||||
|
||||
/** Get a specific order by ID */
|
||||
getOrder(id: number) {
|
||||
return apiClient.get<PaymentOrder>(`/payment/orders/${id}`)
|
||||
},
|
||||
|
||||
/** Cancel a pending order */
|
||||
cancelOrder(id: number) {
|
||||
return apiClient.post(`/payment/orders/${id}/cancel`)
|
||||
},
|
||||
|
||||
/** Verify order payment status with upstream provider */
|
||||
verifyOrder(outTradeNo: string) {
|
||||
return apiClient.post<PaymentOrder>('/payment/orders/verify', { out_trade_no: outTradeNo })
|
||||
},
|
||||
|
||||
/** Verify order payment status without auth (public endpoint for result page) */
|
||||
verifyOrderPublic(outTradeNo: string) {
|
||||
return apiClient.post<PaymentOrder>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
|
||||
},
|
||||
|
||||
/** Request a refund for a completed order */
|
||||
requestRefund(id: number, data: { reason: string }) {
|
||||
return apiClient.post(`/payment/orders/${id}/refund-request`, data)
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export async function list(
|
||||
* @returns Paginated list of usage logs
|
||||
*/
|
||||
export async function query(
|
||||
params: UsageQueryParams,
|
||||
params: UsageQueryParams & { sort_by?: string; sort_order?: 'asc' | 'desc' },
|
||||
config: { signal?: AbortSignal } = {}
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
|
||||
1
frontend/src/assets/icons/alipay.svg
Normal file
1
frontend/src/assets/icons/alipay.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563099286" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1395" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M902.095 652.871l-250.96-84.392s19.287-28.87 39.874-85.472c20.59-56.606 23.539-87.689 23.539-87.689l-162.454-1.339v-55.487l196.739-1.387v-39.227H552.055v-89.29h-96.358v89.294H272.133v39.227l183.564-1.304v59.513h-147.24v31.079h303.064s-3.337 25.223-14.955 56.606c-11.615 31.38-23.58 58.862-23.58 58.862s-142.3-49.804-217.285-49.804c-74.985 0-166.182 30.123-175.024 117.55-8.8 87.383 42.481 134.716 114.728 152.139 72.256 17.513 138.962-0.173 197.04-28.607 58.087-28.391 115.081-92.933 115.081-92.933l292.486 142.041c-11.932 69.3-72.067 119.914-142.387 119.844H266.37c-79.714 0.078-144.392-64.483-144.466-144.194V266.374c-0.074-79.72 64.493-144.399 144.205-144.47h491.519c79.714-0.073 144.396 64.49 144.466 144.203v386.764z m-365.76-48.895s-91.302 115.262-198.879 115.262c-107.623 0-130.218-54.767-130.218-94.155 0-39.34 22.373-82.144 113.943-88.333 91.519-6.18 215.2 67.226 215.2 67.226h-0.047z" fill="#02A9F1" p-id="1396"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
frontend/src/assets/icons/easypay.svg
Normal file
1
frontend/src/assets/icons/easypay.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563141699" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2705" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M647.3728 287.744c-2.048-4.9152-5.7344-9.0112-11.0592-12.0832-5.3248-3.072-12.9024-4.7104-22.9376-4.7104h-221.184c-0.2048 0-0.2048 0.2048-0.2048 0.2048V305.152h260.096c-1.024-6.7584-2.6624-12.4928-4.7104-17.408zM634.0608 400.9984c6.9632-3.2768 11.264-8.192 13.1072-14.5408l5.12-14.7456h-260.096c-0.2048 0-0.2048 0.2048-0.2048 0.2048V405.504c0 0.2048 0.2048 0.2048 0.2048 0.2048h220.9792c6.9632 0.4096 13.9264-1.4336 20.8896-4.7104z" fill="#48D8FF" p-id="2706"></path><path d="M512 1.6384C230.1952 1.6384 1.6384 230.1952 1.6384 512S230.1952 1022.3616 512 1022.3616 1022.3616 793.8048 1022.3616 512 793.8048 1.6384 512 1.6384z m289.5872 644.3008c-0.2048 4.3008-1.2288 20.48-3.2768 48.5376s-4.9152 50.7904-8.8064 67.9936c-3.8912 17.408-13.5168 31.3344-29.0816 41.984-15.5648 10.6496-30.72 16.384-45.2608 17.408-12.9024 0.8192-25.1904-1.024-36.2496-5.9392-11.264-4.9152-20.48-10.8544-27.8528-18.2272-7.3728-7.3728-13.7216-16.5888-19.0464-28.0576-5.3248-11.4688-6.9632-18.0224-4.9152-20.0704 2.048-1.8432 4.096-3.2768 6.3488-3.8912 3.072-0.6144 8.3968-0.2048 15.9744 1.6384s13.9264 2.8672 19.2512 3.072c5.7344 0.4096 12.0832 0 18.8416-1.4336 6.7584-1.4336 12.0832-3.6864 16.384-6.9632 4.096-3.2768 7.3728-7.7824 9.8304-13.7216 2.2528-5.9392 3.8912-13.1072 4.7104-21.504 0.8192-8.3968 1.8432-22.3232 3.072-41.984s2.048-32.5632 2.2528-39.1168v-29.2864c0-6.7584-1.024-11.8784-3.2768-15.36-2.2528-3.4816-7.5776-5.12-15.7696-5.12h-2.4576c-22.3232 0-43.8272 8.6016-60.2112 23.9616-7.9872 7.5776-16.1792 17.408-23.9616 29.4912-7.9872 12.0832-15.5648 25.6-22.9376 40.7552l-18.2272 38.7072c-16.5888 33.9968-36.0448 61.2352-58.5728 81.3056-22.528 20.0704-65.1264 30.72-127.5904 31.9488-14.336 0.4096-24.9856-0.4096-32.1536-2.2528-6.9632-2.048-11.264-4.096-12.4928-6.144-1.2288-2.048-1.024-4.7104 0.4096-7.5776 1.024-2.2528 3.072-3.8912 5.9392-4.9152s11.4688-3.2768 25.8048-6.5536 30.5152-11.0592 48.3328-22.9376c17.8176-11.8784 27.2384-20.0704 35.4304-30.1056 8.192-10.0352 20.6848-26.4192 30.72-44.2368 10.0352-17.8176 24.1664-45.056 34.6112-60.6208 10.0352-15.1552 41.984-53.0432 81.5104-60.0064 0.4096 0 0.4096-0.6144 0-0.6144h-75.1616c-9.4208 0-18.8416 1.8432-27.2384 6.144-7.168 3.6864-10.8544 8.8064-27.0336 36.2496-15.9744 26.8288-36.864 51.2-36.864 51.2-20.2752 25.6-36.6592 43.6224-57.344 56.9344-20.6848 13.312-49.5616 19.0464-87.04 16.9984-12.288-0.4096-23.3472-1.8432-33.3824-4.096-9.8304-2.2528-15.1552-4.3008-15.7696-6.144-0.6144-1.8432-0.4096-3.8912 1.024-5.9392 0.8192-1.4336 4.9152-2.8672 12.288-4.7104 7.3728-1.8432 15.36-4.5056 24.1664-7.9872 8.8064-3.6864 40.3456-15.7696 72.9088-48.5376 28.2624-28.2624 50.3808-60.416 63.8976-73.1136 4.096-3.6864 16.384-13.7216 32.5632-17.408h-54.272c-11.4688 0-20.48 3.4816-29.4912 14.1312-15.9744 18.8416-31.1296 31.1296-46.08 36.0448-18.432 6.144-33.9968 9.4208-46.2848 9.8304h-30.5152c-16.7936 0.6144-25.6-0.6144-26.624-4.096-0.8192-3.2768-0.4096-6.144 1.4336-8.3968 1.8432-2.048 6.144-4.3008 12.6976-6.5536s14.336-6.3488 22.7328-12.0832c8.3968-5.7344 15.5648-11.4688 21.504-16.9984 5.9392-5.5296 12.288-12.9024 19.2512-22.1184l21.0944-29.4912c8.192-11.0592 19.456-22.3232 33.5872-33.9968l31.5392-25.6c0.2048-0.2048 0-0.6144-0.2048-0.6144h-69.0176c-0.2048 0-0.2048-0.2048-0.2048-0.2048V201.9328c0-0.2048 0.2048-0.2048 0.2048-0.2048h288.1536c66.3552 0 103.8336 16.9984 112.2304 50.9952s12.6976 63.0784 12.4928 87.6544c-0.2048 23.7568-2.6624 44.032-7.5776 61.0304-4.9152 16.9984-16.5888 33.5872-35.2256 50.176-18.6368 16.5888-46.08 24.7808-81.92 24.7808h-110.7968c-9.8304 0-19.456 2.8672-27.648 7.9872l-25.1904 15.7696c-3.2768 2.2528-6.144 4.5056-8.3968 6.9632h268.9024c22.9376 0 41.1648 1.2288 54.8864 3.4816 13.7216 2.2528 23.9616 7.7824 30.9248 16.384 6.9632 8.6016 11.0592 18.432 12.288 29.4912 1.2288 11.0592 1.8432 24.3712 1.8432 39.936-0.4096 28.672-0.6144 45.056-0.6144 49.5616z" fill="#48D8FF" p-id="2707"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
1
frontend/src/assets/icons/stripe.svg
Normal file
1
frontend/src/assets/icons/stripe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563184449" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3692" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 512m-448 0a448 448 0 1 0 896 0 448 448 0 1 0-896 0Z" fill="#676BE5" p-id="3693"></path><path d="M471.616 417.296c0-20.96 17.488-29.04 45.488-29.04a300.8 300.8 0 0 1 133.36 34.496v-126.224a353.6 353.6 0 0 0-133.264-24.528c-108.8 0-181.2 56.768-181.2 151.696 0 148.4 203.76 124.336 203.76 188.352 0 24.816-21.52 32.8-51.408 32.8a338.368 338.368 0 0 1-146.704-42.768v120.768a372.272 372.272 0 0 0 146.624 30.4c111.472 0 188.256-48 188.256-144.368 0-160-204.88-131.296-204.88-191.632" fill="#FFFFFF" p-id="3694"></path></svg>
|
||||
|
After Width: | Height: | Size: 859 B |
1
frontend/src/assets/icons/wxpay.svg
Normal file
1
frontend/src/assets/icons/wxpay.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563104166" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1545" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M395.846 603.585c-3.921 1.98-7.936 2.925-12.81 2.925-10.9 0-19.791-5.85-24.764-14.625l-2.006-3.864-78.106-167.913c-0.956-1.98-0.956-3.865-0.956-5.845 0-7.83 5.928-13.68 13.863-13.68 2.965 0 5.928 0.944 8.893 2.924l91.965 64.43c6.884 3.864 14.82 6.79 23.708 6.79 4.972 0 9.85-0.945 14.822-2.926L861.71 282.479c-77.149-89.804-204.684-148.384-349.135-148.384-235.371 0-427.242 157.158-427.242 351.294 0 105.368 57.361 201.017 147.323 265.447 6.88 4.905 11.852 13.68 11.852 22.45 0 2.925-0.957 5.85-2.006 8.775-6.881 26.318-18.831 69.334-18.831 71.223-0.958 2.92-2.013 6.79-2.013 10.75 0 7.83 5.929 13.68 13.865 13.68 2.963 0 5.928-0.944 7.935-2.925l92.922-53.674c6.885-3.87 14.82-6.794 22.756-6.794 3.916 0 8.889 0.944 12.81 1.98 43.496 12.644 91.012 19.53 139.48 19.53 235.372 0 427.24-157.158 427.24-351.294 0-58.58-17.78-114.143-48.467-163.003l-491.39 280.07-2.963 1.98z" fill="#09BB07" p-id="1546"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
|
||||
const privacyOpts = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allPrivacyModes') },
|
||||
{ value: '__unset__', label: t('admin.accounts.privacyUnset') },
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import AccountTableFilters from '../AccountTableFilters.vue'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('AccountTableFilters', () => {
|
||||
it('renders privacy mode options and emits privacy_mode updates', async () => {
|
||||
const wrapper = mount(AccountTableFilters, {
|
||||
props: {
|
||||
searchQuery: '',
|
||||
filters: {
|
||||
platform: '',
|
||||
type: '',
|
||||
status: '',
|
||||
group: '',
|
||||
privacy_mode: ''
|
||||
},
|
||||
groups: []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SearchInput: {
|
||||
template: '<div />'
|
||||
},
|
||||
Select: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
template: '<div class="select-stub" :data-options="JSON.stringify(options)" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const selects = wrapper.findAll('.select-stub')
|
||||
expect(selects).toHaveLength(5)
|
||||
|
||||
const privacyOptions = JSON.parse(selects[3].attributes('data-options'))
|
||||
expect(privacyOptions).toEqual([
|
||||
{ value: '', label: 'admin.accounts.allPrivacyModes' },
|
||||
{ value: '__unset__', label: 'admin.accounts.privacyUnset' },
|
||||
{ value: 'training_off', label: 'Privacy' },
|
||||
{ value: 'training_set_cf_blocked', label: 'CF' },
|
||||
{ value: 'training_set_failed', label: 'Fail' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="items" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="email"
|
||||
default-sort-order="asc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-email="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -62,7 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
@@ -98,23 +106,54 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const sortState = reactive({
|
||||
sort_by: 'email',
|
||||
sort_order: 'asc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const items = ref<AnnouncementUserReadStatus[]>([])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'email', label: t('common.email') },
|
||||
{ key: 'username', label: t('admin.users.columns.username') },
|
||||
{ key: 'balance', label: t('common.balance') },
|
||||
{ key: 'email', label: t('common.email'), sortable: true },
|
||||
{ key: 'username', label: t('admin.users.columns.username'), sortable: true },
|
||||
{ key: 'balance', label: t('common.balance'), sortable: true },
|
||||
{ key: 'eligible', label: t('admin.announcements.eligible') },
|
||||
{ key: 'read_at', label: t('admin.announcements.readAt') }
|
||||
])
|
||||
|
||||
let currentController: AbortController | null = null
|
||||
let searchDebounceTimer: number | null = null
|
||||
|
||||
function resetDialogState() {
|
||||
loading.value = false
|
||||
search.value = ''
|
||||
items.value = []
|
||||
pagination.page = 1
|
||||
pagination.total = 0
|
||||
pagination.pages = 0
|
||||
sortState.sort_by = 'email'
|
||||
sortState.sort_order = 'asc'
|
||||
}
|
||||
|
||||
function cancelPendingLoad(resetState = false) {
|
||||
if (searchDebounceTimer) {
|
||||
window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
currentController?.abort()
|
||||
currentController = null
|
||||
if (resetState) {
|
||||
resetDialogState()
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.show || !props.announcementId) return
|
||||
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
currentController?.abort()
|
||||
const requestController = new AbortController()
|
||||
currentController = requestController
|
||||
const { signal } = requestController
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -122,20 +161,37 @@ async function load() {
|
||||
props.announcementId,
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
search.value
|
||||
{
|
||||
search: search.value,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
if (signal.aborted || currentController !== requestController) return
|
||||
|
||||
items.value = res.items
|
||||
pagination.total = res.total
|
||||
pagination.pages = res.pages
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
if (
|
||||
signal.aborted ||
|
||||
currentController !== requestController ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to load read status:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (currentController === requestController) {
|
||||
loading.value = false
|
||||
currentController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
|
||||
load()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
@@ -160,13 +222,17 @@ function handleSearch() {
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
cancelPendingLoad(true)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
if (!v) {
|
||||
cancelPendingLoad(true)
|
||||
return
|
||||
}
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
@@ -181,7 +247,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// noop
|
||||
onUnmounted(() => {
|
||||
cancelPendingLoad()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import AnnouncementReadStatusDialog from '../AnnouncementReadStatusDialog.vue'
|
||||
|
||||
const { getReadStatus, showError } = vi.hoisted(() => ({
|
||||
getReadStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
announcements: {
|
||||
getReadStatus,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/usePersistedPageSize', () => ({
|
||||
getPersistedPageSize: () => 20,
|
||||
}))
|
||||
|
||||
const BaseDialogStub = {
|
||||
props: ['show', 'title', 'width'],
|
||||
emits: ['close'],
|
||||
template: '<div><slot /><slot name="footer" /></div>',
|
||||
}
|
||||
|
||||
describe('AnnouncementReadStatusDialog', () => {
|
||||
beforeEach(() => {
|
||||
getReadStatus.mockReset()
|
||||
showError.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('closes by aborting active requests and clearing debounced reloads', async () => {
|
||||
let activeSignal: AbortSignal | undefined
|
||||
getReadStatus.mockImplementation(async (...args: any[]) => {
|
||||
activeSignal = args[4]?.signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const wrapper = mount(AnnouncementReadStatusDialog, {
|
||||
props: {
|
||||
show: false,
|
||||
announcementId: 1,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
DataTable: true,
|
||||
Pagination: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.setProps({ show: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(getReadStatus).toHaveBeenCalledTimes(1)
|
||||
expect(activeSignal?.aborted).toBe(false)
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.search = 'alice'
|
||||
setupState.handleSearch()
|
||||
|
||||
setupState.handleClose()
|
||||
await flushPromises()
|
||||
|
||||
expect(activeSignal?.aborted).toBe(true)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(350)
|
||||
await flushPromises()
|
||||
|
||||
expect(getReadStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -196,7 +196,6 @@
|
||||
:total="localEntries.length"
|
||||
:page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@update:page="currentPage = $event"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
|
||||
139
frontend/src/components/admin/payment/AdminOrderDetail.vue
Normal file
139
frontend/src/components/admin/payment/AdminOrderDetail.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('payment.admin.orderDetail')"
|
||||
width="wide"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div v-if="order" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p>
|
||||
<p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ order.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p>
|
||||
<span :class="['badge', statusBadgeClass(order.status)]">
|
||||
{{ t('payment.status.' + order.status.toLowerCase(), order.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.amount.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.pay_amount.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.methods.' + order.payment_type, order.payment_type) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ (order.fee_rate * 100).toFixed(1) }}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderType') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.admin.' + order.order_type + 'Order', order.order_type) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.userId') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">#{{ order.user_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.expires_at) }}</p>
|
||||
</div>
|
||||
<div v-if="order.paid_at">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.paid_at) }}</p>
|
||||
</div>
|
||||
<div v-if="order.completed_at">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.completedAt') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.completed_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="order.refund_amount"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20"
|
||||
>
|
||||
<h4 class="mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
|
||||
{{ t('payment.admin.refundInfo') }}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span>
|
||||
<span class="ml-1 font-medium text-red-700 dark:text-red-300">${{ order.refund_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="order.refund_reason" class="col-span-2">
|
||||
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span>
|
||||
<span class="ml-1 text-red-700 dark:text-red-300">{{ order.refund_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||
<button
|
||||
v-if="order.status === 'PENDING'"
|
||||
@click="emit('cancel', order)"
|
||||
class="btn btn-sm rounded-md bg-yellow-50 px-3 py-1.5 text-sm text-yellow-600 hover:bg-yellow-100 dark:bg-yellow-900/20 dark:text-yellow-400 dark:hover:bg-yellow-900/30"
|
||||
>
|
||||
{{ t('payment.orders.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="order.status === 'FAILED'"
|
||||
@click="emit('retry', order)"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>
|
||||
{{ t('payment.admin.retry') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canRefund(order)"
|
||||
@click="emit('refund', order)"
|
||||
class="btn btn-sm rounded-md bg-red-50 px-3 py-1.5 text-sm text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30"
|
||||
>
|
||||
{{ t('payment.admin.refund') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } from '@/components/payment/orderUtils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
order: PaymentOrder | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'cancel', order: PaymentOrder): void
|
||||
(e: 'retry', order: PaymentOrder): void
|
||||
(e: 'refund', order: PaymentOrder): void
|
||||
}>()
|
||||
|
||||
function canRefund(order: PaymentOrder): boolean {
|
||||
return canRefundStatus(order.status)
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return formatOrderDateTime(dateStr)
|
||||
}
|
||||
</script>
|
||||
227
frontend/src/components/admin/payment/AdminOrderTable.vue
Normal file
227
frontend/src/components/admin/payment/AdminOrderTable.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex-1 sm:max-w-64">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('payment.admin.searchOrders')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusFilterOptions"
|
||||
class="w-36"
|
||||
@change="emitFiltersChanged"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.payment_type"
|
||||
:options="paymentTypeFilterOptions"
|
||||
class="w-40"
|
||||
@change="emitFiltersChanged"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.order_type"
|
||||
:options="orderTypeFilterOptions"
|
||||
class="w-36"
|
||||
@change="emitFiltersChanged"
|
||||
/>
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
@click="emit('refresh')"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="orders" :loading="loading">
|
||||
<template #cell-id="{ value }">
|
||||
<span class="font-mono text-sm">#{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user_id="{ value }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-amount="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">
|
||||
({{ t('payment.orders.payAmount') }}: ${{ row.pay_amount.toFixed(2) }})
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-payment_type="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.methods.' + value, value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', statusBadgeClass(value)]">
|
||||
{{ t('payment.status.' + value.toLowerCase(), value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-order_type="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.admin.' + value + 'Order', value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="emit('detail', row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800/50 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
<span class="text-xs">{{ t('common.view') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'PENDING'"
|
||||
@click="emit('cancel', row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400"
|
||||
>
|
||||
<Icon name="x" size="sm" />
|
||||
<span class="text-xs">{{ t('payment.orders.cancel') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'FAILED'"
|
||||
@click="emit('retry', 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"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
<span class="text-xs">{{ t('payment.admin.retry') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRefundRow(row)"
|
||||
@click="emit('refund', 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"
|
||||
>
|
||||
<Icon name="dollar" size="sm" />
|
||||
<span class="text-xs">{{ t('payment.admin.refund') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:page="page"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:pageSize="emit('update:pageSize', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { statusBadgeClass, canRefund, formatOrderDateTime } from '@/components/payment/orderUtils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
orders: PaymentOrder[]
|
||||
loading: boolean
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'detail', order: PaymentOrder): void
|
||||
(e: 'cancel', order: PaymentOrder): void
|
||||
(e: 'retry', order: PaymentOrder): void
|
||||
(e: 'refund', order: PaymentOrder): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'update:page', page: number): void
|
||||
(e: 'update:pageSize', size: number): void
|
||||
(e: 'filter', filters: { keyword?: string; status?: string; payment_type?: string; order_type?: string }): void
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({ status: '', payment_type: '', order_type: '' })
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function handleSearch() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => emitFiltersChanged(), 300)
|
||||
}
|
||||
|
||||
function emitFiltersChanged() {
|
||||
emit('filter', {
|
||||
keyword: searchQuery.value || undefined,
|
||||
status: filters.status || undefined,
|
||||
payment_type: filters.payment_type || undefined,
|
||||
order_type: filters.order_type || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'id', label: t('payment.orders.orderId') },
|
||||
{ key: 'user_id', label: t('payment.orders.userId') },
|
||||
{ key: 'amount', label: t('payment.orders.amount') },
|
||||
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
|
||||
{ key: 'status', label: t('payment.orders.status') },
|
||||
{ key: 'order_type', label: t('payment.orders.orderType') },
|
||||
{ key: 'created_at', label: t('payment.orders.createdAt') },
|
||||
{ key: 'actions', label: t('payment.orders.actions') },
|
||||
])
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: t('payment.admin.allStatuses') },
|
||||
{ value: 'PENDING', label: t('payment.status.pending') },
|
||||
{ value: 'PAID', label: t('payment.status.paid') },
|
||||
{ value: 'COMPLETED', label: t('payment.status.completed') },
|
||||
{ value: 'EXPIRED', label: t('payment.status.expired') },
|
||||
{ value: 'CANCELLED', label: t('payment.status.cancelled') },
|
||||
{ value: 'FAILED', label: t('payment.status.failed') },
|
||||
{ value: 'REFUNDED', label: t('payment.status.refunded') },
|
||||
{ value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') },
|
||||
{ value: 'REFUND_FAILED', label: t('payment.status.refund_failed') },
|
||||
])
|
||||
|
||||
const paymentTypeFilterOptions = computed(() => [
|
||||
{ value: '', label: t('payment.admin.allPaymentTypes') },
|
||||
{ value: 'alipay', label: t('payment.methods.alipay') },
|
||||
{ value: 'wxpay', label: t('payment.methods.wxpay') },
|
||||
{ value: 'stripe', label: t('payment.methods.stripe') },
|
||||
])
|
||||
|
||||
const orderTypeFilterOptions = computed(() => [
|
||||
{ value: '', label: t('payment.admin.allOrderTypes') },
|
||||
{ value: 'balance', label: t('payment.admin.balanceOrder') },
|
||||
{ value: 'subscription', label: t('payment.admin.subscriptionOrder') },
|
||||
])
|
||||
|
||||
function canRefundRow(order: PaymentOrder): boolean {
|
||||
return canRefund(order.status)
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return formatOrderDateTime(dateStr)
|
||||
}
|
||||
</script>
|
||||
234
frontend/src/components/admin/payment/AdminRefundDialog.vue
Normal file
234
frontend/src/components/admin/payment/AdminRefundDialog.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('payment.admin.refundOrder')"
|
||||
width="normal"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<form id="refund-form" @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Refund Request Info -->
|
||||
<div
|
||||
v-if="order?.refund_requested_at || order?.refund_request_reason"
|
||||
class="rounded-lg border border-violet-200 bg-violet-50 p-3 dark:border-violet-800 dark:bg-violet-900/20"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-violet-700 dark:text-violet-300">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ t('payment.admin.refundRequestInfo') }}
|
||||
</div>
|
||||
<div v-if="order?.refund_requested_at" class="mt-2 flex justify-between text-sm">
|
||||
<span class="text-violet-600 dark:text-violet-400">{{ t('payment.admin.refundRequestedAt') }}</span>
|
||||
<span class="text-violet-800 dark:text-violet-200">{{ formatDateTime(order.refund_requested_at) }}</span>
|
||||
</div>
|
||||
<div v-if="order?.refund_request_reason" class="mt-1 text-sm">
|
||||
<span class="text-violet-600 dark:text-violet-400">{{ t('payment.admin.refundRequestReason') }}:</span>
|
||||
<span class="ml-1 text-violet-800 dark:text-violet-200">{{ order.refund_request_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Info -->
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">#{{ order?.id }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="actuallyRefunded > 0" class="mt-1 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span>
|
||||
<span class="font-medium text-red-600 dark:text-red-400">${{ actuallyRefunded.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deduct Balance -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="deduct-balance"
|
||||
v-model="form.deduct_balance"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label for="deduct-balance" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.admin.deductBalance') }}
|
||||
</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.deductBalanceHint') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- User Balance Info (when deduct_balance is checked) -->
|
||||
<div v-if="form.deduct_balance && userBalance != null" class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
|
||||
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.userBalance') }}</div>
|
||||
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ userBalance.toFixed(2) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
|
||||
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderAmount') }}</div>
|
||||
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insufficient balance warning -->
|
||||
<div
|
||||
v-if="form.deduct_balance && balanceInsufficient"
|
||||
class="mt-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
>
|
||||
{{ t('payment.admin.insufficientBalance') }}
|
||||
</div>
|
||||
|
||||
<!-- No deduction info -->
|
||||
<div
|
||||
v-if="!form.deduct_balance"
|
||||
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
>
|
||||
{{ t('payment.admin.noDeduction') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refund Amount -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.refundAmount') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
:max="maxRefundable"
|
||||
class="input pl-7"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('payment.admin.maxRefundable') }}: ${{ maxRefundable.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.refundReason') }}</label>
|
||||
<textarea
|
||||
v-model="form.reason"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('payment.admin.refundReasonPlaceholder')"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div
|
||||
v-if="warning"
|
||||
class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300"
|
||||
>
|
||||
{{ warning }}
|
||||
</div>
|
||||
|
||||
<!-- Force Refund -->
|
||||
<div v-if="requireForce" class="flex items-center gap-2">
|
||||
<input
|
||||
id="force-refund"
|
||||
v-model="form.force"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<label for="force-refund" class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('payment.admin.forceRefund') }}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="emit('cancel')" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="refund-form"
|
||||
:disabled="submitting || form.amount <= 0 || (requireForce && !form.force)"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-dark-800"
|
||||
>
|
||||
{{ submitting ? t('common.processing') : t('payment.admin.confirmRefund') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import { formatOrderDateTime } from '@/components/payment/orderUtils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
order: PaymentOrder | null
|
||||
submitting?: boolean
|
||||
userBalance?: number | null
|
||||
requireForce?: boolean
|
||||
warning?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
amount: 0,
|
||||
reason: '',
|
||||
deduct_balance: true,
|
||||
force: false,
|
||||
})
|
||||
|
||||
// In REFUND_REQUESTED status, refund_amount is the REQUESTED amount, not actually refunded.
|
||||
// Only PARTIALLY_REFUNDED / REFUNDED have real refund amounts.
|
||||
const actuallyRefunded = computed(() => {
|
||||
if (!props.order) return 0
|
||||
const s = props.order.status
|
||||
if (s === 'PARTIALLY_REFUNDED' || s === 'REFUNDED') return props.order.refund_amount || 0
|
||||
return 0
|
||||
})
|
||||
|
||||
const maxRefundable = computed(() => {
|
||||
if (!props.order) return 0
|
||||
return props.order.pay_amount - actuallyRefunded.value
|
||||
})
|
||||
|
||||
const balanceInsufficient = computed(() => {
|
||||
if (props.userBalance == null || !props.order) return false
|
||||
return props.userBalance < props.order.pay_amount
|
||||
})
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.order) {
|
||||
// For REFUND_REQUESTED, pre-fill with the requested amount
|
||||
if (props.order.status === 'REFUND_REQUESTED' && props.order.refund_amount) {
|
||||
form.amount = props.order.refund_amount
|
||||
} else {
|
||||
form.amount = maxRefundable.value
|
||||
}
|
||||
form.reason = props.order.refund_request_reason || ''
|
||||
form.deduct_balance = true
|
||||
form.force = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return formatOrderDateTime(dateStr)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (form.amount <= 0 || form.amount > maxRefundable.value) return
|
||||
if (props.requireForce && !form.force) return
|
||||
emit('confirm', { ...form })
|
||||
}
|
||||
</script>
|
||||
99
frontend/src/components/admin/payment/DailyRevenueChart.vue
Normal file
99
frontend/src/components/admin/payment/DailyRevenueChart.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('payment.admin.dailyRevenue') }}
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<Line v-else-if="chartData" :data="chartData" :options="chartOptions" />
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('payment.admin.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
data: { date: string; amount: number; count: number }[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.data || props.data.length === 0) return null
|
||||
return {
|
||||
labels: props.data.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: t('payment.admin.revenue'),
|
||||
data: props.data.map(d => d.amount),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
{
|
||||
label: t('payment.admin.orderCount'),
|
||||
data: props.data.map(d => d.count),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index' as const, intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
title: { display: true, text: t('payment.admin.revenue') },
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
title: { display: true, text: t('payment.admin.orderCount') },
|
||||
grid: { drawOnChartArea: false },
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: 'top' as const },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
77
frontend/src/components/admin/payment/OrderStatsCards.vue
Normal file
77
frontend/src/components/admin/payment/OrderStatsCards.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Today Revenue -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||
<Icon name="dollar" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.today_amount) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.today_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Revenue -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<Icon name="creditCard" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.totalRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.total_amount) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.total_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Orders -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||
<Icon name="chart" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayOrders') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Average Amount -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
|
||||
<Icon name="chart" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.avgAmount') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.avg_amount) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { DashboardStats } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
stats: DashboardStats
|
||||
}>()
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
75
frontend/src/components/admin/payment/PaymentMethodChart.vue
Normal file
75
frontend/src/components/admin/payment/PaymentMethodChart.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('payment.admin.paymentDistribution') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="!methods?.length"
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('payment.admin.noData') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="method in methods" :key="method.type" class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['inline-block h-3 w-3 rounded-full', colorMap[method.type] || 'bg-gray-400']"></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.methods.' + method.type, method.type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${{ method.amount.toFixed(2) }}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ method.count }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full rounded-full transition-all', barColorMap[method.type] || 'bg-gray-400']"
|
||||
:style="{ width: barWidth(method.amount) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
methods: { type: string; amount: number; count: number }[]
|
||||
}>()
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
alipay: 'bg-blue-500',
|
||||
wxpay: 'bg-green-500',
|
||||
alipay_direct: 'bg-blue-400',
|
||||
wxpay_direct: 'bg-green-400',
|
||||
stripe: 'bg-purple-500',
|
||||
}
|
||||
|
||||
const barColorMap: Record<string, string> = {
|
||||
alipay: 'bg-blue-500',
|
||||
wxpay: 'bg-green-500',
|
||||
alipay_direct: 'bg-blue-400',
|
||||
wxpay_direct: 'bg-green-400',
|
||||
stripe: 'bg-purple-500',
|
||||
}
|
||||
|
||||
const maxAmount = computed(() => {
|
||||
if (!props.methods?.length) return 1
|
||||
return Math.max(...props.methods.map(m => m.amount), 1)
|
||||
})
|
||||
|
||||
function barWidth(amount: number): number {
|
||||
return Math.min((amount / maxAmount.value) * 100, 100)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('payment.admin.topUsers') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="!users?.length"
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('payment.admin.noData') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(user, idx) in users"
|
||||
:key="user.user_id"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
:class="[
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
|
||||
rankClass(idx),
|
||||
]"
|
||||
>
|
||||
{{ idx + 1 }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${{ user.amount.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
users: { user_id: number; email: string; amount: number }[]
|
||||
}>()
|
||||
|
||||
function rankClass(idx: number): string {
|
||||
if (idx === 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
if (idx === 1) return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
if (idx === 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400'
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-auto">
|
||||
<DataTable :columns="columns" :data="data" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:server-side-sort="serverSideSort"
|
||||
:default-sort-key="defaultSortKey"
|
||||
:default-sort-order="defaultSortOrder"
|
||||
@sort="(key, order) => $emit('sort', key, order)"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<button
|
||||
@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
defineProps(['data', 'loading', 'columns'])
|
||||
defineEmits(['userClick'])
|
||||
interface Props {
|
||||
data: AdminUsageLog[]
|
||||
loading?: boolean
|
||||
columns: Column[]
|
||||
serverSideSort?: boolean
|
||||
defaultSortKey?: string
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
serverSideSort: false,
|
||||
defaultSortKey: '',
|
||||
defaultSortOrder: 'asc'
|
||||
})
|
||||
defineEmits<{
|
||||
userClick: [userID: number, email?: string]
|
||||
sort: [key: string, order: 'asc' | 'desc']
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Tooltip state - cost
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
'is-scrollable': isScrollable
|
||||
}"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<table class="w-full min-w-max divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="table-header bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<th
|
||||
@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col {
|
||||
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* ==========================================================================
|
||||
终极悬浮滚动条防丢器 (Sledgehammer Override)
|
||||
绕过 style.css 中 `* { scrollbar-color: transparent }` 的全局悬停隐身诅咒!
|
||||
========================================================================== */
|
||||
|
||||
/* 1. 废除全局针对所有元素的 scrollbar-width 设定,拿回 Chrome/Safari 下 Webkit 滚动条规则的控制权! */
|
||||
.table-wrapper {
|
||||
scrollbar-width: auto !important; /* 阻止 Chrome 121 退化到原生 Mac 闪隐滚动条 */
|
||||
}
|
||||
|
||||
/* 2. 重写 Webkit 滚动层,全部加上 !important 强制覆盖透明悬停陷阱 */
|
||||
.table-wrapper::-webkit-scrollbar {
|
||||
height: 12px !important;
|
||||
width: 12px !important;
|
||||
display: block !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.table-wrapper::-webkit-scrollbar-track {
|
||||
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||
border-radius: 6px !important;
|
||||
margin: 0 4px !important;
|
||||
}
|
||||
.dark .table-wrapper::-webkit-scrollbar-track {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
/* 常驻、不透明的滑块,无视鼠标是否 hover 都在那! */
|
||||
.table-wrapper::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(107, 114, 128, 0.75) !important;
|
||||
border-radius: 6px !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: padding-box !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .table-wrapper::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.75) !important;
|
||||
}
|
||||
.dark .table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(209, 213, 219, 0.9) !important;
|
||||
}
|
||||
|
||||
/* 3. 仅给真正的 Firefox 留的后路 */
|
||||
@supports (-moz-appearance:none) {
|
||||
.table-wrapper {
|
||||
scrollbar-width: thin !important;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
.dark .table-wrapper {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,7 +122,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from './Select.vue'
|
||||
import { setPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -141,7 +141,7 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100],
|
||||
pageSizeOptions: () => getConfiguredTablePageSizeOptions(),
|
||||
showPageSizeSelector: true,
|
||||
showJump: false
|
||||
})
|
||||
@@ -161,7 +161,14 @@ const toItem = computed(() => {
|
||||
})
|
||||
|
||||
const pageSizeSelectOptions = computed(() => {
|
||||
return props.pageSizeOptions.map((size) => ({
|
||||
const options = Array.from(
|
||||
new Set([
|
||||
...getConfiguredTablePageSizeOptions(),
|
||||
normalizeTablePageSize(props.pageSize)
|
||||
])
|
||||
).sort((a, b) => a - b)
|
||||
|
||||
return options.map((size) => ({
|
||||
value: size,
|
||||
label: String(size)
|
||||
}))
|
||||
@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => {
|
||||
|
||||
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||
if (value === null || typeof value === 'boolean') return
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
setPersistedPageSize(newPageSize)
|
||||
const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value)
|
||||
emit('update:pageSize', newPageSize)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,28 +27,70 @@
|
||||
<template v-if="isAdmin">
|
||||
<!-- Admin Section -->
|
||||
<div class="sidebar-section">
|
||||
<router-link
|
||||
v-for="item in adminNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
:id="
|
||||
item.path === '/admin/accounts'
|
||||
? 'sidebar-channel-manage'
|
||||
: item.path === '/admin/groups'
|
||||
? 'sidebar-group-manage'
|
||||
: item.path === '/admin/redeem'
|
||||
? 'sidebar-wallet'
|
||||
: undefined
|
||||
"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
|
||||
</router-link>
|
||||
<template v-for="item in adminNavItems" :key="item.path">
|
||||
<!-- Collapsible group (has children) -->
|
||||
<template v-if="item.children?.length">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-link mb-1 w-full"
|
||||
:class="{
|
||||
'sidebar-link-active': isGroupActive(item) && !isGroupExpanded(item),
|
||||
'sidebar-link-collapsed': sidebarCollapsed
|
||||
}"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="sidebarCollapsed ? undefined : toggleGroup(item)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span
|
||||
class="sidebar-label sidebar-label-flex"
|
||||
:class="{ 'sidebar-label-collapsed': sidebarCollapsed }"
|
||||
:aria-hidden="sidebarCollapsed ? 'true' : 'false'"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
<ChevronDownIcon
|
||||
class="h-4 w-4 flex-shrink-0 transition-transform duration-200"
|
||||
:class="isGroupExpanded(item) ? 'rotate-180' : ''"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Children -->
|
||||
<div v-if="!sidebarCollapsed && isGroupExpanded(item)" class="mb-1 ml-4 border-l border-gray-200 pl-2 dark:border-dark-600">
|
||||
<router-link
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
:to="child.path"
|
||||
class="sidebar-link mb-0.5 py-1.5 text-sm"
|
||||
:class="{ 'sidebar-link-active': route.path === child.path }"
|
||||
@click="handleMenuItemClick(child.path)"
|
||||
>
|
||||
<component :is="child.icon" class="h-4 w-4 flex-shrink-0" />
|
||||
<span>{{ child.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Normal item (no children) -->
|
||||
<router-link
|
||||
v-else
|
||||
:to="item.path"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
:id="
|
||||
item.path === '/admin/accounts'
|
||||
? 'sidebar-channel-manage'
|
||||
: item.path === '/admin/groups'
|
||||
? 'sidebar-group-manage'
|
||||
: item.path === '/admin/redeem'
|
||||
? 'sidebar-wallet'
|
||||
: undefined
|
||||
"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Personal Section for Admin (hidden in simple mode) -->
|
||||
@@ -151,6 +193,7 @@ interface NavItem {
|
||||
icon: unknown
|
||||
iconSvg?: string
|
||||
hideInSimpleMode?: boolean
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -166,6 +209,9 @@ const mobileOpen = computed(() => appStore.mobileOpen)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
// Track which parent nav groups are expanded
|
||||
const expandedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// Site settings from appStore (cached, no flicker)
|
||||
const siteName = computed(() => appStore.siteName)
|
||||
const siteLogo = computed(() => appStore.siteLogo)
|
||||
@@ -458,6 +504,36 @@ const ChevronDoubleLeftIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const OrderIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const OrderListIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDoubleRightIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -473,6 +549,21 @@ const ChevronDoubleRightIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDownIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'm19.5 8.25-7.5 7.5-7.5-7.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
@@ -480,14 +571,24 @@ const userNavItems = computed((): NavItem[] => {
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
...(appStore.cachedPublicSettings?.payment_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: RechargeSubscriptionIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(appStore.cachedPublicSettings?.payment_enabled
|
||||
? [
|
||||
{
|
||||
path: '/orders',
|
||||
label: t('nav.myOrders'),
|
||||
icon: OrderListIcon,
|
||||
hideInSimpleMode: true
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
@@ -508,14 +609,24 @@ const personalNavItems = computed((): NavItem[] => {
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
...(appStore.cachedPublicSettings?.payment_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: RechargeSubscriptionIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(appStore.cachedPublicSettings?.payment_enabled
|
||||
? [
|
||||
{
|
||||
path: '/orders',
|
||||
label: t('nav.myOrders'),
|
||||
icon: OrderListIcon,
|
||||
hideInSimpleMode: true
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
@@ -560,6 +671,21 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
{ 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 },
|
||||
...(adminSettingsStore.paymentEnabled
|
||||
? [
|
||||
{
|
||||
path: '/admin/orders',
|
||||
label: t('nav.orderManagement'),
|
||||
icon: OrderIcon,
|
||||
hideInSimpleMode: true,
|
||||
children: [
|
||||
{ path: '/admin/orders/dashboard', label: t('nav.paymentDashboard'), icon: ChartIcon },
|
||||
{ path: '/admin/orders', label: t('nav.orderManagement'), icon: OrderIcon },
|
||||
{ path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
|
||||
]
|
||||
|
||||
@@ -621,6 +747,23 @@ function isActive(path: string): boolean {
|
||||
return route.path === path || route.path.startsWith(path + '/')
|
||||
}
|
||||
|
||||
function isGroupActive(item: NavItem): boolean {
|
||||
if (!item.children) return false
|
||||
return item.children.some(child => route.path === child.path)
|
||||
}
|
||||
|
||||
function isGroupExpanded(item: NavItem): boolean {
|
||||
return expandedGroups.value.has(item.path) || isGroupActive(item)
|
||||
}
|
||||
|
||||
function toggleGroup(item: NavItem) {
|
||||
if (expandedGroups.value.has(item.path)) {
|
||||
expandedGroups.value.delete(item.path)
|
||||
} else {
|
||||
expandedGroups.value.add(item.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (
|
||||
@@ -752,6 +895,13 @@ onMounted(() => {
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.sidebar-label-flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-label-collapsed {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
@@ -759,11 +909,14 @@ onMounted(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Custom SVG icon in sidebar: inherit color, constrain size */
|
||||
/* Custom SVG icon in sidebar: constrain size without overriding uploaded SVG colors */
|
||||
.sidebar-svg-icon {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.sidebar-svg-icon :deep(svg) {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
18
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
Normal file
18
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AppSidebar.vue')
|
||||
const componentSource = readFileSync(componentPath, 'utf8')
|
||||
|
||||
describe('AppSidebar custom SVG styles', () => {
|
||||
it('does not override uploaded SVG fill or stroke colors', () => {
|
||||
expect(componentSource).toContain('.sidebar-svg-icon {')
|
||||
expect(componentSource).toContain('color: currentColor;')
|
||||
expect(componentSource).toContain('display: block;')
|
||||
expect(componentSource).not.toContain('stroke: currentColor;')
|
||||
expect(componentSource).not.toContain('fill: none;')
|
||||
})
|
||||
})
|
||||
111
frontend/src/components/payment/AmountInput.vue
Normal file
111
frontend/src/components/payment/AmountInput.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Quick Amount Buttons -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.quickAmounts') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="amt in filteredAmounts"
|
||||
:key="amt"
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors',
|
||||
modelValue === amt
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
|
||||
]"
|
||||
@click="selectAmount(amt)"
|
||||
>
|
||||
{{ amt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Amount Input -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.customAmount') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
:value="customText"
|
||||
:placeholder="placeholderText"
|
||||
class="input w-full py-3 pl-8 pr-4"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
amounts?: number[]
|
||||
modelValue: number | null
|
||||
min?: number
|
||||
max?: number
|
||||
}>(), {
|
||||
amounts: () => [10, 20, 50, 100, 200, 500, 1000, 2000, 5000],
|
||||
min: 0,
|
||||
max: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const customText = ref('')
|
||||
|
||||
// 0 = no limit
|
||||
const filteredAmounts = computed(() =>
|
||||
props.amounts.filter((a) => (props.min <= 0 || a >= props.min) && (props.max <= 0 || a <= props.max))
|
||||
)
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
if (props.min > 0 && props.max > 0) return `${props.min} - ${props.max}`
|
||||
if (props.min > 0) return `≥ ${props.min}`
|
||||
if (props.max > 0) return `≤ ${props.max}`
|
||||
return t('payment.enterAmount')
|
||||
})
|
||||
|
||||
const AMOUNT_PATTERN = /^\d*(\.\d{0,2})?$/
|
||||
|
||||
function selectAmount(amt: number) {
|
||||
customText.value = String(amt)
|
||||
emit('update:modelValue', amt)
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (!AMOUNT_PATTERN.test(val)) return
|
||||
customText.value = val
|
||||
if (val === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const num = parseFloat(val)
|
||||
if (!isNaN(num) && num > 0) {
|
||||
emit('update:modelValue', num)
|
||||
} else {
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v !== null && String(v) !== customText.value) {
|
||||
customText.value = String(v)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
45
frontend/src/components/payment/OrderStatusBadge.vue
Normal file
45
frontend/src/components/payment/OrderStatusBadge.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="statusClass"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { OrderStatus } from '@/types/payment'
|
||||
|
||||
const props = defineProps<{
|
||||
status: OrderStatus
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const statusMap: Record<OrderStatus, { key: string; class: string }> = {
|
||||
PENDING: { key: 'payment.status.pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
PAID: { key: 'payment.status.paid', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
RECHARGING: { key: 'payment.status.recharging', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
COMPLETED: { key: 'payment.status.completed', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
|
||||
EXPIRED: { key: 'payment.status.expired', class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400' },
|
||||
CANCELLED: { key: 'payment.status.cancelled', class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400' },
|
||||
FAILED: { key: 'payment.status.failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
|
||||
REFUND_REQUESTED: { key: 'payment.status.refund_requested', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
REFUNDING: { key: 'payment.status.refunding', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
REFUNDED: { key: 'payment.status.refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
PARTIALLY_REFUNDED: { key: 'payment.status.partially_refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
REFUND_FAILED: { key: 'payment.status.refund_failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const entry = statusMap[props.status]
|
||||
return entry ? t(entry.key) : props.status
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const entry = statusMap[props.status]
|
||||
return entry?.class ?? 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
|
||||
})
|
||||
</script>
|
||||
71
frontend/src/components/payment/OrderTable.vue
Normal file
71
frontend/src/components/payment/OrderTable.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="orders" :loading="loading">
|
||||
<template #cell-id="{ value }">
|
||||
<span class="font-mono text-sm">#{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-out_trade_no="{ value }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
<template v-if="showUser" #cell-user_email="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-900 dark:text-white">{{ value || row.user_name || '#' + row.user_id }}</span>
|
||||
<span v-if="row.user_notes" class="ml-1 text-xs text-gray-400">({{ row.user_notes }})</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-amount="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">(${{ row.pay_amount.toFixed(2) }})</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-payment_type="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + value, value) }}</span>
|
||||
</template>
|
||||
<template #cell-status="{ value }">
|
||||
<OrderStatusBadge :status="value" />
|
||||
</template>
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(value) }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<slot name="actions" :row="row" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
orders: PaymentOrder[]
|
||||
loading: boolean
|
||||
showUser?: boolean
|
||||
}>()
|
||||
|
||||
function formatDate(dateStr: string) { return new Date(dateStr).toLocaleString() }
|
||||
|
||||
const columns = computed((): Column[] => {
|
||||
const cols: Column[] = [
|
||||
{ key: 'id', label: t('payment.orders.orderId') },
|
||||
{ key: 'out_trade_no', label: t('payment.orders.orderNo') },
|
||||
]
|
||||
if (props.showUser) {
|
||||
cols.push({ key: 'user_email', label: t('payment.admin.colUser') })
|
||||
}
|
||||
cols.push(
|
||||
{ key: 'amount', label: t('payment.orders.amount') },
|
||||
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
|
||||
{ key: 'status', label: t('payment.orders.status') },
|
||||
{ key: 'created_at', label: t('payment.orders.createdAt') },
|
||||
{ key: 'actions', label: t('common.actions') },
|
||||
)
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
91
frontend/src/components/payment/PaymentMethodSelector.vue
Normal file
91
frontend/src/components/payment/PaymentMethodSelector.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.paymentMethod') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3 sm:flex">
|
||||
<button
|
||||
v-for="method in sortedMethods"
|
||||
:key="method.type"
|
||||
type="button"
|
||||
:disabled="!method.available"
|
||||
:class="[
|
||||
'relative flex h-[60px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||
!method.available
|
||||
? 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50'
|
||||
: selected === method.type
|
||||
? methodSelectedClass(method.type)
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
|
||||
]"
|
||||
@click="method.available && emit('select', method.type)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7" />
|
||||
<span class="flex flex-col items-start leading-none">
|
||||
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
|
||||
<span
|
||||
v-if="method.fee_rate > 0"
|
||||
class="text-[10px] tracking-wide text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('payment.fee') }} {{ method.fee_rate }}%
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { METHOD_ORDER } from './providerConfig'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
import stripeIcon from '@/assets/icons/stripe.svg'
|
||||
|
||||
export interface PaymentMethodOption {
|
||||
type: string
|
||||
fee_rate: number
|
||||
available: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
methods: PaymentMethodOption[]
|
||||
selected: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [type: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const METHOD_ICONS: Record<string, string> = {
|
||||
alipay: alipayIcon,
|
||||
wxpay: wxpayIcon,
|
||||
stripe: stripeIcon,
|
||||
}
|
||||
|
||||
const sortedMethods = computed(() => {
|
||||
const order: readonly string[] = METHOD_ORDER
|
||||
return [...props.methods].sort((a, b) => {
|
||||
const ai = order.indexOf(a.type)
|
||||
const bi = order.indexOf(b.type)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
})
|
||||
|
||||
function methodIcon(type: string): string {
|
||||
if (type.includes('alipay')) return METHOD_ICONS.alipay
|
||||
if (type.includes('wxpay')) return METHOD_ICONS.wxpay
|
||||
return METHOD_ICONS[type] || alipayIcon
|
||||
}
|
||||
|
||||
function methodSelectedClass(type: string): string {
|
||||
if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
|
||||
if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
|
||||
if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
|
||||
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
|
||||
}
|
||||
</script>
|
||||
497
frontend/src/components/payment/PaymentProviderDialog.vue
Normal file
497
frontend/src/components/payment/PaymentProviderDialog.vue
Normal file
@@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="editing ? t('admin.settings.payment.editProvider') : t('admin.settings.payment.createProvider')"
|
||||
width="wide"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<form id="provider-form" @submit.prevent="handleSave" class="space-y-4">
|
||||
<!-- Name + Key -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.settings.payment.providerName') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input v-model="form.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.settings.payment.providerKey') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.provider_key"
|
||||
:options="(!!editing ? allKeyOptions : enabledKeyOptions) as SelectOption[]"
|
||||
:disabled="!!editing"
|
||||
@change="onKeyChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggles + Payment mode + Supported types (single row) -->
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
|
||||
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled" />
|
||||
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="mode in paymentModeOptions"
|
||||
:key="mode.value"
|
||||
type="button"
|
||||
@click="form.payment_mode = mode.value"
|
||||
:class="[
|
||||
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
|
||||
form.payment_mode === mode.value
|
||||
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
|
||||
]"
|
||||
>{{ mode.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="availableTypes.length > 1" class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.supportedTypes') }}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="pt in availableTypes"
|
||||
:key="pt.value"
|
||||
type="button"
|
||||
@click="toggleType(pt.value)"
|
||||
:class="[
|
||||
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
|
||||
isTypeSelected(pt.value)
|
||||
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
|
||||
]"
|
||||
>{{ pt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Config fields -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.providerConfig') }}
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<div v-for="field in resolvedFields" :key="field.key">
|
||||
<label class="input-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.optional" class="text-xs text-gray-400">({{ t('common.optional') }})</span>
|
||||
<span v-else class="text-red-500"> *</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="field.sensitive && field.key.toLowerCase().includes('key') && field.key !== 'pkey'"
|
||||
v-model="config[field.key]"
|
||||
rows="3"
|
||||
class="input font-mono text-xs"
|
||||
/>
|
||||
<div v-else-if="field.sensitive" class="relative">
|
||||
<input
|
||||
:type="visibleFields[field.key] ? 'text' : 'password'"
|
||||
v-model="config[field.key]"
|
||||
class="input pr-10"
|
||||
:placeholder="field.defaultValue || ''"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="visibleFields[field.key] = !visibleFields[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg v-if="visibleFields[field.key]" 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" /></svg>
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
v-model="config[field.key]"
|
||||
class="input"
|
||||
:placeholder="field.defaultValue || ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Callback URLs (each = editable URL + fixed path) -->
|
||||
<div v-if="callbackPaths" class="mt-4 space-y-3">
|
||||
<div v-if="callbackPaths.notifyUrl">
|
||||
<label class="input-label">{{ t('admin.settings.payment.field_notifyUrl') }} <span class="text-red-500">*</span></label>
|
||||
<div class="flex">
|
||||
<input v-model="notifyBaseUrl" type="text" class="input min-w-0 flex-1 !rounded-r-none !border-r-0" :placeholder="defaultBaseUrl" />
|
||||
<span class="inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400">{{ callbackPaths.notifyUrl }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="callbackPaths.returnUrl">
|
||||
<label class="input-label">{{ t('admin.settings.payment.field_returnUrl') }} <span class="text-red-500">*</span></label>
|
||||
<div class="flex">
|
||||
<input v-model="returnBaseUrl" type="text" class="input min-w-0 flex-1 !rounded-r-none !border-r-0" :placeholder="defaultBaseUrl" />
|
||||
<span class="inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400">{{ callbackPaths.returnUrl }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stripe webhook hint -->
|
||||
<div v-if="stripeWebhookUrl" class="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800/50 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.settings.payment.stripeWebhookHint') }}
|
||||
</p>
|
||||
<code class="mt-1 block break-all rounded bg-blue-100 px-2 py-1 text-xs text-blue-800 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
{{ stripeWebhookUrl }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-type limits (collapsible) -->
|
||||
<div v-if="limitableTypes.length" class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||
<button type="button" @click="limitsExpanded = !limitsExpanded" class="flex w-full items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.limitsTitle') }}
|
||||
</h4>
|
||||
<svg :class="['h-4 w-4 text-gray-400 transition-transform', limitsExpanded && 'rotate-180']" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
<div v-show="limitsExpanded" class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="lt in limitableTypes"
|
||||
:key="lt.value"
|
||||
class="rounded-lg border border-gray-100 p-3 dark:border-dark-700"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">{{ lt.label }}</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitSingleMin') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="getLimitVal(lt.value, 'singleMin')"
|
||||
@input="setLimitVal(lt.value, 'singleMin', ($event.target as HTMLInputElement).value)"
|
||||
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitSingleMax') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="getLimitVal(lt.value, 'singleMax')"
|
||||
@input="setLimitVal(lt.value, 'singleMax', ($event.target as HTMLInputElement).value)"
|
||||
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitDaily') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="getLimitVal(lt.value, 'dailyLimit')"
|
||||
@input="setLimitVal(lt.value, 'dailyLimit', ($event.target as HTMLInputElement).value)"
|
||||
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('admin.settings.payment.limitsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="provider-form" :disabled="saving" class="btn btn-primary">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import type { SelectOption } from '@/components/common/Select.vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import {
|
||||
PROVIDER_CONFIG_FIELDS,
|
||||
PROVIDER_SUPPORTED_TYPES,
|
||||
PROVIDER_CALLBACK_PATHS,
|
||||
WEBHOOK_PATHS,
|
||||
PAYMENT_MODE_QRCODE,
|
||||
PAYMENT_MODE_POPUP,
|
||||
getAvailableTypes,
|
||||
extractBaseUrl,
|
||||
} from './providerConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
saving: boolean
|
||||
editing: ProviderInstance | null
|
||||
allKeyOptions: TypeOption[]
|
||||
enabledKeyOptions: TypeOption[]
|
||||
allPaymentTypes: TypeOption[]
|
||||
redirectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [payload: {
|
||||
provider_key: string
|
||||
name: string
|
||||
supported_types: string[]
|
||||
enabled: boolean
|
||||
payment_mode: string
|
||||
refund_enabled: boolean
|
||||
config: Record<string, string>
|
||||
limits: string
|
||||
}]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// --- Form state ---
|
||||
const form = reactive({
|
||||
name: '',
|
||||
provider_key: 'easypay',
|
||||
supported_types: [] as string[],
|
||||
enabled: true,
|
||||
payment_mode: PAYMENT_MODE_QRCODE,
|
||||
refund_enabled: false,
|
||||
})
|
||||
const config = reactive<Record<string, string>>({})
|
||||
const limits = reactive<Record<string, Record<string, number>>>({})
|
||||
const notifyBaseUrl = ref('')
|
||||
const returnBaseUrl = ref('')
|
||||
const limitsExpanded = ref(false)
|
||||
const visibleFields = reactive<Record<string, boolean>>({})
|
||||
|
||||
// --- Computed ---
|
||||
const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
|
||||
const stripeWebhookUrl = computed(() =>
|
||||
form.provider_key === 'stripe' ? defaultBaseUrl + WEBHOOK_PATHS.stripe : '',
|
||||
)
|
||||
|
||||
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
|
||||
|
||||
const paymentModeOptions = computed(() => {
|
||||
return [
|
||||
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
|
||||
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
|
||||
]
|
||||
})
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
|
||||
// Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe)
|
||||
return base.map(opt =>
|
||||
opt.label === opt.value
|
||||
? { ...opt, label: t(`payment.methods.${opt.value}`, opt.value) }
|
||||
: opt,
|
||||
)
|
||||
})
|
||||
|
||||
const resolvedFields = computed(() => {
|
||||
const fields = PROVIDER_CONFIG_FIELDS[form.provider_key] || []
|
||||
return fields.map(f => ({
|
||||
...f,
|
||||
label: f.label || t(`admin.settings.payment.field_${f.key}`),
|
||||
}))
|
||||
})
|
||||
|
||||
const limitableTypes = computed(() => {
|
||||
// Stripe: single "stripe" entry (one set of shared limits)
|
||||
if (form.provider_key === 'stripe') {
|
||||
return [{ value: 'stripe', label: 'Stripe' }]
|
||||
}
|
||||
const selected = form.supported_types.filter(t => t !== 'easypay')
|
||||
return selected.map(v => {
|
||||
const found = props.allPaymentTypes.find(pt => pt.value === v)
|
||||
return found || { value: v, label: v }
|
||||
})
|
||||
})
|
||||
|
||||
// --- Methods ---
|
||||
function isTypeSelected(type: string): boolean {
|
||||
return form.supported_types.includes(type)
|
||||
}
|
||||
|
||||
function toggleType(type: string) {
|
||||
if (form.supported_types.includes(type)) {
|
||||
form.supported_types = form.supported_types.filter(t => t !== type)
|
||||
} else {
|
||||
form.supported_types = [...form.supported_types, type]
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyChange() {
|
||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
|
||||
clearConfig()
|
||||
applyDefaults()
|
||||
}
|
||||
|
||||
function clearConfig() {
|
||||
Object.keys(config).forEach(k => delete config[k])
|
||||
Object.keys(limits).forEach(k => delete limits[k])
|
||||
Object.keys(visibleFields).forEach(k => delete visibleFields[k])
|
||||
notifyBaseUrl.value = ''
|
||||
returnBaseUrl.value = ''
|
||||
limitsExpanded.value = false
|
||||
}
|
||||
|
||||
function applyDefaults() {
|
||||
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
|
||||
if (f.defaultValue && !config[f.key]) config[f.key] = f.defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
function getLimitVal(paymentType: string, field: string): string {
|
||||
const val = limits[paymentType]?.[field]
|
||||
return val && val > 0 ? String(val) : ''
|
||||
}
|
||||
|
||||
/** Returns true if any limit field for this payment type has a value */
|
||||
function hasAnyLimit(paymentType: string): boolean {
|
||||
const l = limits[paymentType]
|
||||
if (!l) return false
|
||||
return (l.singleMin > 0) || (l.singleMax > 0) || (l.dailyLimit > 0)
|
||||
}
|
||||
|
||||
/** Dynamic placeholder: "不限制" if sibling has value, "使用全局配置" if all empty */
|
||||
function limitPlaceholder(paymentType: string): string {
|
||||
return hasAnyLimit(paymentType)
|
||||
? t('admin.settings.payment.limitsNoLimit')
|
||||
: t('admin.settings.payment.limitsUseGlobal')
|
||||
}
|
||||
|
||||
function setLimitVal(paymentType: string, field: string, val: string) {
|
||||
if (!limits[paymentType]) limits[paymentType] = {}
|
||||
const num = Number(val)
|
||||
// Empty → clear the field (use global); reject ≤0
|
||||
if (val === '' || isNaN(num)) {
|
||||
delete limits[paymentType][field]
|
||||
return
|
||||
}
|
||||
if (num <= 0) return
|
||||
limits[paymentType][field] = num
|
||||
}
|
||||
|
||||
function serializeLimits(): string {
|
||||
const result: Record<string, Record<string, number>> = {}
|
||||
for (const [pt, fields] of Object.entries(limits)) {
|
||||
const clean: Record<string, number> = {}
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v > 0) clean[k] = v
|
||||
}
|
||||
if (Object.keys(clean).length > 0) result[pt] = clean
|
||||
}
|
||||
return Object.keys(result).length > 0 ? JSON.stringify(result) : ''
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
// Validate required fields
|
||||
if (!form.name.trim()) {
|
||||
emitValidationError(t('admin.settings.payment.validationNameRequired'))
|
||||
return
|
||||
}
|
||||
// Validate required config fields — all non-optional fields must be filled
|
||||
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
|
||||
if (f.optional) continue
|
||||
const val = (config[f.key] || '').trim()
|
||||
if (!val) {
|
||||
const label = f.label || t(`admin.settings.payment.field_${f.key}`)
|
||||
emitValidationError(t('admin.settings.payment.validationFieldRequired', { field: label }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const filteredConfig: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
if (!v || !v.trim()) continue
|
||||
// Skip masked values — backend keeps existing credentials
|
||||
if (v === '••••••••') continue
|
||||
filteredConfig[k] = v
|
||||
}
|
||||
|
||||
// Inject computed callback URLs (each URL = independent base + fixed path)
|
||||
// If base URL is empty, auto-fill with current domain
|
||||
const paths = PROVIDER_CALLBACK_PATHS[form.provider_key]
|
||||
if (paths) {
|
||||
const notifyBase = notifyBaseUrl.value.trim() || defaultBaseUrl
|
||||
const returnBase = returnBaseUrl.value.trim() || defaultBaseUrl
|
||||
notifyBaseUrl.value = notifyBase
|
||||
returnBaseUrl.value = returnBase
|
||||
if (paths.notifyUrl) filteredConfig['notifyUrl'] = notifyBase + paths.notifyUrl
|
||||
if (paths.returnUrl) filteredConfig['returnUrl'] = returnBase + paths.returnUrl
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
provider_key: form.provider_key,
|
||||
name: form.name,
|
||||
supported_types: form.supported_types,
|
||||
enabled: form.enabled,
|
||||
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
|
||||
refund_enabled: form.refund_enabled,
|
||||
config: filteredConfig,
|
||||
limits: serializeLimits(),
|
||||
})
|
||||
}
|
||||
|
||||
function emitValidationError(msg: string) {
|
||||
// Use a custom event or inject appStore — for now use window alert fallback
|
||||
// The parent handles this via the save event validation
|
||||
import('@/stores').then(m => m.useAppStore().showError(msg))
|
||||
}
|
||||
|
||||
// --- Public API for parent to call ---
|
||||
function reset(defaultKey: string) {
|
||||
form.name = ''
|
||||
form.provider_key = defaultKey
|
||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
|
||||
form.enabled = true
|
||||
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
|
||||
form.refund_enabled = false
|
||||
clearConfig()
|
||||
applyDefaults()
|
||||
}
|
||||
|
||||
function loadProvider(provider: ProviderInstance) {
|
||||
form.name = provider.name
|
||||
form.provider_key = provider.provider_key
|
||||
form.supported_types = provider.supported_types
|
||||
form.enabled = provider.enabled
|
||||
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
|
||||
form.refund_enabled = provider.refund_enabled
|
||||
clearConfig()
|
||||
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
|
||||
if (provider.config) {
|
||||
for (const [k, v] of Object.entries(provider.config)) {
|
||||
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl
|
||||
if (k === 'notifyUrl' || k === 'returnUrl') continue
|
||||
config[k] = v
|
||||
}
|
||||
// Extract base URLs from existing callback URLs
|
||||
const paths = PROVIDER_CALLBACK_PATHS[provider.provider_key]
|
||||
if (paths?.notifyUrl && provider.config['notifyUrl']) {
|
||||
notifyBaseUrl.value = extractBaseUrl(provider.config['notifyUrl'], paths.notifyUrl)
|
||||
}
|
||||
if (paths?.returnUrl && provider.config['returnUrl']) {
|
||||
returnBaseUrl.value = extractBaseUrl(provider.config['returnUrl'], paths.returnUrl)
|
||||
}
|
||||
}
|
||||
applyDefaults()
|
||||
// Parse existing limits
|
||||
if (provider.limits) {
|
||||
try {
|
||||
const parsed = JSON.parse(provider.limits)
|
||||
for (const [pt, fields] of Object.entries(parsed as Record<string, Record<string, number>>)) {
|
||||
limits[pt] = { ...fields }
|
||||
}
|
||||
limitsExpanded.value = Object.keys(limits).length > 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset, loadProvider })
|
||||
</script>
|
||||
150
frontend/src/components/payment/PaymentProviderList.vue
Normal file
150
frontend/src/components/payment/PaymentProviderList.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-dark-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.providerManagement') }}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.payment.providerManagementDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('refresh')"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="sm" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('create')"
|
||||
:disabled="!canCreate"
|
||||
:class="canCreate
|
||||
? 'btn btn-primary btn-sm'
|
||||
: 'btn btn-secondary btn-sm cursor-not-allowed opacity-50'"
|
||||
>
|
||||
{{ t('admin.settings.payment.createProvider') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="p-4">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !providers.length" class="flex items-center justify-center py-6">
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-primary-500 border-t-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Provider cards (draggable) -->
|
||||
<VueDraggable
|
||||
v-if="providers.length"
|
||||
v-model="localProviders"
|
||||
:animation="200"
|
||||
handle=".drag-handle"
|
||||
class="space-y-3"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<div v-for="p in localProviders" :key="p.id" class="flex items-start gap-2">
|
||||
<div class="drag-handle mt-3 flex cursor-grab items-center text-gray-300 hover:text-gray-500 active:cursor-grabbing dark:text-dark-600 dark:hover:text-dark-400">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<ProviderCard
|
||||
:provider="p"
|
||||
:enabled="isEnabled(p.provider_key)"
|
||||
:available-types="getTypes(p.provider_key)"
|
||||
@toggle-field="(field) => emit('toggleField', p, field)"
|
||||
@toggle-type="(type) => emit('toggleType', p, type)"
|
||||
@edit="emit('edit', p)"
|
||||
@delete="emit('delete', p)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!loading" class="py-6 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ canCreate
|
||||
? t('admin.settings.payment.noProviders')
|
||||
: t('admin.settings.payment.enableTypesFirst') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
v-if="canCreate"
|
||||
@click="emit('create')"
|
||||
class="btn btn-primary btn-sm mt-2"
|
||||
>
|
||||
{{ t('admin.settings.payment.createProvider') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProviderCard from './ProviderCard.vue'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import { getAvailableTypes } from './providerConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
providers: ProviderInstance[]
|
||||
loading: boolean
|
||||
canCreate: boolean
|
||||
enabledPaymentTypes: string[]
|
||||
allPaymentTypes: TypeOption[]
|
||||
redirectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
create: []
|
||||
edit: [provider: ProviderInstance]
|
||||
delete: [provider: ProviderInstance]
|
||||
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled']
|
||||
toggleType: [provider: ProviderInstance, type: string]
|
||||
reorder: [providers: { id: number; sort_order: number }[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const localProviders = ref<ProviderInstance[]>([])
|
||||
|
||||
watch(() => props.providers, (val) => {
|
||||
localProviders.value = [...val]
|
||||
}, { immediate: true })
|
||||
|
||||
function onDragEnd() {
|
||||
const updates = localProviders.value.map((p, idx) => ({
|
||||
id: p.id,
|
||||
sort_order: idx,
|
||||
}))
|
||||
emit('reorder', updates)
|
||||
}
|
||||
|
||||
function isEnabled(providerKey: string): boolean {
|
||||
return props.enabledPaymentTypes.includes(providerKey)
|
||||
}
|
||||
|
||||
function getTypes(providerKey: string): TypeOption[] {
|
||||
return getAvailableTypes(providerKey, props.allPaymentTypes, props.redirectLabel)
|
||||
.map(opt => opt.label === opt.value
|
||||
? { ...opt, label: t(`payment.methods.${opt.value}`, opt.value) }
|
||||
: opt,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
272
frontend/src/components/payment/PaymentQRDialog.vue
Normal file
272
frontend/src/components/payment/PaymentQRDialog.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="dialogTitle" width="narrow" @close="handleClose">
|
||||
<!-- QR Code + Polling State -->
|
||||
<div v-if="!success" class="flex flex-col items-center space-y-4">
|
||||
<!-- QR Code mode -->
|
||||
<template v-if="qrUrl">
|
||||
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
</div>
|
||||
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ scanHint }}
|
||||
</p>
|
||||
</template>
|
||||
<!-- Popup window waiting mode (no QR code) -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
<button v-if="payUrl" class="btn btn-secondary mt-3 text-sm" @click="reopenPopup">
|
||||
{{ t('payment.qr.openPayWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Countdown -->
|
||||
<div v-if="expired" class="text-center">
|
||||
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : '' }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Success State -->
|
||||
<div v-else class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Icon name="check" size="lg" class="text-green-500" />
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
|
||||
<div v-if="paidOrder" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ paidOrder.id }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button v-if="!success && !expired" class="btn btn-secondary" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
<button v-if="success" class="btn btn-primary" @click="handleDone">
|
||||
{{ t('common.confirm') }}
|
||||
</button>
|
||||
<button v-if="expired" class="btn btn-primary" @click="handleClose">
|
||||
{{ t('payment.result.backToRecharge') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
orderId: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
/** URL for reopening the payment popup window */
|
||||
payUrl?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const qrUrl = ref('')
|
||||
const remainingSeconds = ref(0)
|
||||
const expired = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const success = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isAlipay = computed(() => props.paymentType.includes('alipay'))
|
||||
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (success.value) return t('payment.result.success')
|
||||
if (!qrUrl.value) return t('payment.qr.payInNewWindow')
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpay')
|
||||
return t('payment.qr.scanToPay')
|
||||
})
|
||||
|
||||
const scanHint = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
|
||||
return ''
|
||||
})
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
const m = Math.floor(remainingSeconds.value / 60)
|
||||
const s = remainingSeconds.value % 60
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
function getLogoForType(): string | null {
|
||||
if (isAlipay.value) return alipayIcon
|
||||
if (isWxpay.value) return wxpayIcon
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
function reopenPopup() {
|
||||
if (props.payUrl) {
|
||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
const logoSrc = getLogoForType()
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: logoSrc ? 'H' : 'M',
|
||||
})
|
||||
if (!logoSrc) return
|
||||
const canvas = qrCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const img = new Image()
|
||||
img.src = logoSrc
|
||||
img.onload = () => {
|
||||
const logoSize = 40
|
||||
const x = (canvas.width - logoSize) / 2
|
||||
const y = (canvas.height - logoSize) / 2
|
||||
const pad = 4
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
const r = 5
|
||||
ctx.moveTo(x - pad + r, y - pad)
|
||||
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
|
||||
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
|
||||
ctx.fill()
|
||||
ctx.drawImage(img, x, y, logoSize, logoSize)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!props.orderId) return
|
||||
const order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
success.value = true
|
||||
emit('success')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
return
|
||||
}
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
cleanup()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!props.orderId || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
cleanup()
|
||||
emit('close')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
cleanup()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
cleanup()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Reset state
|
||||
success.value = false
|
||||
paidOrder.value = null
|
||||
expired.value = false
|
||||
cancelling.value = false
|
||||
qrUrl.value = props.qrCode
|
||||
|
||||
let seconds = 30 * 60
|
||||
if (props.expiresAt) {
|
||||
const expiresAt = new Date(props.expiresAt)
|
||||
seconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000)
|
||||
}
|
||||
startCountdown(seconds)
|
||||
pollTimer = setInterval(pollStatus, 3000)
|
||||
renderQR()
|
||||
}
|
||||
|
||||
// Watch for dialog open/close
|
||||
watch(() => props.show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
init()
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
watch(qrUrl, () => renderQR())
|
||||
|
||||
onUnmounted(() => cleanup())
|
||||
</script>
|
||||
266
frontend/src/components/payment/PaymentStatusPanel.vue
Normal file
266
frontend/src/components/payment/PaymentStatusPanel.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- ═══ Terminal States: show result, user clicks to return ═══ -->
|
||||
|
||||
<!-- Success -->
|
||||
<template v-if="outcome === 'success'">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Icon name="check" size="lg" class="text-green-500" />
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ props.orderType === 'subscription' ? t('payment.result.subscriptionSuccess') : t('payment.result.success') }}</p>
|
||||
<div v-if="paidOrder" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ paidOrder.id }}</span>
|
||||
</div>
|
||||
<div v-if="paidOrder.out_trade_no" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.out_trade_no }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cancelled -->
|
||||
<template v-else-if="outcome === 'cancelled'">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.cancelled') }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.cancelledDesc') }}</p>
|
||||
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Expired / Failed -->
|
||||
<template v-else-if="outcome === 'expired'">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
|
||||
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.expired') }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiredDesc') }}</p>
|
||||
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Active States: QR or Popup waiting ═══ -->
|
||||
|
||||
<!-- QR Code Mode -->
|
||||
<template v-else-if="qrUrl">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ scanTitle }}</p>
|
||||
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
<!-- Brand logo overlay -->
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
|
||||
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Waiting for Popup/Redirect Mode -->
|
||||
<template v-else>
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
<button v-if="payUrl" class="btn btn-secondary text-sm" @click="reopenPopup">
|
||||
{{ t('payment.qr.openPayWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
|
||||
const props = defineProps<{
|
||||
orderId: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
payUrl?: string
|
||||
orderType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ done: []; success: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const qrUrl = ref('')
|
||||
const remainingSeconds = ref(0)
|
||||
const cancelling = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
|
||||
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
|
||||
const outcome = ref<'success' | 'cancelled' | 'expired' | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isAlipay = computed(() => props.paymentType.includes('alipay'))
|
||||
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
|
||||
|
||||
const qrBorderClass = computed(() => {
|
||||
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
|
||||
if (isWxpay.value) return 'border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20'
|
||||
return 'border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800'
|
||||
})
|
||||
|
||||
const qrLogoBgClass = computed(() => {
|
||||
if (isAlipay.value) return 'bg-[#00AEEF]'
|
||||
if (isWxpay.value) return 'bg-[#2BB741]'
|
||||
return 'bg-gray-400'
|
||||
})
|
||||
|
||||
const scanTitle = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpay')
|
||||
return t('payment.qr.scanToPay')
|
||||
})
|
||||
|
||||
const scanHint = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
|
||||
return ''
|
||||
})
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
const m = Math.floor(remainingSeconds.value / 60)
|
||||
const s = remainingSeconds.value % 60
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
function reopenPopup() {
|
||||
if (props.payUrl) {
|
||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 220, margin: 2,
|
||||
errorCorrectionLevel: 'H',
|
||||
})
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!props.orderId || outcome.value) return
|
||||
const order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
outcome.value = 'success'
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
outcome.value = 'expired'
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; return }
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; cleanup() }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!props.orderId || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDone() { cleanup(); emit('done') }
|
||||
|
||||
function cleanup() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
qrUrl.value = props.qrCode
|
||||
let seconds = 30 * 60
|
||||
if (props.expiresAt) {
|
||||
seconds = Math.floor((new Date(props.expiresAt).getTime() - Date.now()) / 1000)
|
||||
}
|
||||
startCountdown(seconds)
|
||||
pollTimer = setInterval(pollStatus, 3000)
|
||||
renderQR()
|
||||
|
||||
watch(() => qrUrl.value, () => renderQR())
|
||||
onUnmounted(() => cleanup())
|
||||
</script>
|
||||
106
frontend/src/components/payment/ProviderCard.vue
Normal file
106
frontend/src/components/payment/ProviderCard.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'group relative rounded-lg border transition-all',
|
||||
enabled ? 'border-gray-200 dark:border-dark-600' : 'border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50',
|
||||
]"
|
||||
:title="!enabled ? t('admin.settings.payment.typeDisabled') + ' — ' + t('admin.settings.payment.enableTypesFirst') : undefined"
|
||||
>
|
||||
<div :class="[
|
||||
'flex items-center justify-between px-4 py-2.5',
|
||||
!enabled && 'pointer-events-none',
|
||||
]">
|
||||
<!-- Left: icon + name + key badge + type badges -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="[
|
||||
'rounded-md p-1.5',
|
||||
provider.enabled && enabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-dark-700',
|
||||
]">
|
||||
<Icon
|
||||
name="server"
|
||||
size="sm"
|
||||
:class="provider.enabled && enabled ? 'text-green-600 dark:text-green-400' : 'text-gray-400'"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ provider.name }}</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ keyLabel }}</span>
|
||||
<span v-if="provider.payment_mode" class="text-xs text-gray-400 dark:text-gray-500">· {{ modeLabel }}</span>
|
||||
<span v-if="enabled && availableTypes.length" class="text-xs text-gray-300 dark:text-gray-600">|</span>
|
||||
<div v-if="enabled" class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="pt in availableTypes"
|
||||
:key="pt.value"
|
||||
type="button"
|
||||
@click="emit('toggleType', pt.value)"
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs font-medium transition-all',
|
||||
isSelected(pt.value)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-500',
|
||||
]"
|
||||
>{{ pt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: toggles + actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<ToggleSwitch :label="t('common.enabled')" :checked="provider.enabled" @toggle="emit('toggleField', 'enabled')" />
|
||||
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="provider.refund_enabled" @toggle="emit('toggleField', 'refund_enabled')" />
|
||||
<div class="flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600">
|
||||
<button type="button" @click="emit('edit')" 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">
|
||||
<Icon name="edit" size="sm" />
|
||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||
</button>
|
||||
<button type="button" @click="emit('delete')" 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">
|
||||
<Icon name="trash" size="sm" />
|
||||
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import { PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP } from './providerConfig'
|
||||
|
||||
const PROVIDER_KEY_LABELS: Record<string, string> = {
|
||||
easypay: 'admin.settings.payment.providerEasypay',
|
||||
alipay: 'admin.settings.payment.providerAlipay',
|
||||
wxpay: 'admin.settings.payment.providerWxpay',
|
||||
stripe: 'admin.settings.payment.providerStripe',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderInstance
|
||||
enabled: boolean
|
||||
availableTypes: TypeOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleField: [field: 'enabled' | 'refund_enabled']
|
||||
toggleType: [type: string]
|
||||
edit: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_key] || props.provider.provider_key))
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
|
||||
return ''
|
||||
})
|
||||
|
||||
function isSelected(type: string): boolean {
|
||||
return props.provider.supported_types.includes(type)
|
||||
}
|
||||
</script>
|
||||
201
frontend/src/components/payment/StripePaymentInline.vue
Normal file
201
frontend/src/components/payment/StripePaymentInline.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div v-else-if="initError" class="card p-6 text-center">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ initError }}</p>
|
||||
<button class="btn btn-secondary mt-4" @click="$emit('back')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<!-- Success -->
|
||||
<template v-else-if="success">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Icon name="check" size="lg" class="text-green-500" />
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
|
||||
<div class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ orderId }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ payAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="$emit('done')">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Amount -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center">
|
||||
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-white">${{ payAmount.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stripe Payment Element -->
|
||||
<div class="card p-6">
|
||||
<div ref="stripeMount" class="min-h-[200px]"></div>
|
||||
<p v-if="error" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="submitting || !ready" @click="handlePay">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.stripePay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Cancel order -->
|
||||
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
// Stripe payment methods that open a popup (redirect or QR code)
|
||||
const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
|
||||
|
||||
const props = defineProps<{
|
||||
orderId: number
|
||||
clientSecret: string
|
||||
publishableKey: string
|
||||
payAmount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ success: []; done: []; back: []; redirect: [orderId: number, payUrl: string] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const stripeMount = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const initError = ref('')
|
||||
const error = ref('')
|
||||
const submitting = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const success = ref(false)
|
||||
const ready = ref(false)
|
||||
const selectedType = ref('')
|
||||
|
||||
let stripeInstance: Stripe | null = null
|
||||
let elementsInstance: StripeElements | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(props.publishableKey)
|
||||
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
stripeInstance = stripe
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
if (!stripeMount.value) return
|
||||
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const elements = stripe.elements({
|
||||
clientSecret: props.clientSecret,
|
||||
appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } },
|
||||
})
|
||||
elementsInstance = elements
|
||||
const paymentElement = elements.create('payment', {
|
||||
layout: 'tabs',
|
||||
paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'],
|
||||
} as Record<string, unknown>)
|
||||
paymentElement.mount(stripeMount.value)
|
||||
paymentElement.on('ready', () => { ready.value = true })
|
||||
paymentElement.on('change', (event: { value: { type: string } }) => {
|
||||
selectedType.value = event.value.type
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function handlePay() {
|
||||
if (!stripeInstance || !elementsInstance || submitting.value) return
|
||||
|
||||
// Alipay / WeChat Pay: open popup for redirect or QR display
|
||||
if (POPUP_METHODS.has(selectedType.value)) {
|
||||
const popupUrl = router.resolve({
|
||||
path: '/payment/stripe-popup',
|
||||
query: {
|
||||
order_id: String(props.orderId),
|
||||
method: selectedType.value,
|
||||
amount: String(props.payAmount),
|
||||
},
|
||||
}).href
|
||||
const popup = window.open(popupUrl, 'paymentPopup', STRIPE_POPUP_WINDOW_FEATURES)
|
||||
|
||||
const onReady = (event: MessageEvent) => {
|
||||
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return
|
||||
window.removeEventListener('message', onReady)
|
||||
popup?.postMessage({
|
||||
type: 'STRIPE_POPUP_INIT',
|
||||
clientSecret: props.clientSecret,
|
||||
publishableKey: props.publishableKey,
|
||||
}, window.location.origin)
|
||||
}
|
||||
window.addEventListener('message', onReady)
|
||||
|
||||
emit('redirect', props.orderId, popupUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Card / Link: confirm inline
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { error: stripeError } = await stripeInstance.confirmPayment({
|
||||
elements: elementsInstance,
|
||||
confirmParams: {
|
||||
return_url: window.location.origin + '/payment/result?order_id=' + props.orderId + '&status=success',
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
if (stripeError) {
|
||||
error.value = stripeError.message || t('payment.result.failed')
|
||||
} else {
|
||||
success.value = true
|
||||
emit('success')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!props.orderId || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
emit('back')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
161
frontend/src/components/payment/SubscriptionPlanCard.vue
Normal file
161
frontend/src/components/payment/SubscriptionPlanCard.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
|
||||
'hover:shadow-xl hover:-translate-y-0.5',
|
||||
borderClass,
|
||||
'bg-white dark:bg-dark-800',
|
||||
]"
|
||||
>
|
||||
<!-- Colored top accent bar -->
|
||||
<div :class="['h-1.5', accentClass]" />
|
||||
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
<!-- Header: name + badge + price -->
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="truncate text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
|
||||
<span :class="['shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]">
|
||||
{{ pLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="plan.description" class="mt-0.5 text-xs leading-relaxed text-gray-500 dark:text-dark-400 line-clamp-2">
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-xs text-gray-400 dark:text-dark-500">$</span>
|
||||
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
|
||||
<div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5">
|
||||
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}</span>
|
||||
<span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group quota info (compact) -->
|
||||
<div class="mb-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-dark-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span>
|
||||
</div>
|
||||
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between">
|
||||
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span>
|
||||
</div>
|
||||
<div v-if="plan.weekly_limit_usd != null" class="flex items-center justify-between">
|
||||
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.weeklyLimit') }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.weekly_limit_usd }}</span>
|
||||
</div>
|
||||
<div v-if="plan.monthly_limit_usd != null" class="flex items-center justify-between">
|
||||
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.monthlyLimit') }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.monthly_limit_usd }}</span>
|
||||
</div>
|
||||
<div v-if="plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null" class="flex items-center justify-between">
|
||||
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.quota') }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.planCard.unlimited') }}</span>
|
||||
</div>
|
||||
<div v-if="modelScopeLabels.length > 0" class="col-span-2 flex items-center justify-between">
|
||||
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.models') }}</span>
|
||||
<div class="flex flex-wrap justify-end gap-1">
|
||||
<span v-for="scope in modelScopeLabels" :key="scope"
|
||||
class="rounded bg-gray-200/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300">
|
||||
{{ scope }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features list (compact) -->
|
||||
<div v-if="plan.features.length > 0" class="mb-3 space-y-1">
|
||||
<div v-for="feature in plan.features" :key="feature" class="flex items-start gap-1.5">
|
||||
<svg :class="['mt-0.5 h-3.5 w-3.5 flex-shrink-0', iconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Subscribe Button -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
|
||||
@click="emit('select', plan)"
|
||||
>
|
||||
{{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { UserSubscription } from '@/types'
|
||||
import {
|
||||
platformAccentBarClass,
|
||||
platformBadgeLightClass,
|
||||
platformBorderClass,
|
||||
platformTextClass,
|
||||
platformIconClass,
|
||||
platformButtonClass,
|
||||
platformDiscountClass,
|
||||
platformLabel,
|
||||
} from '@/utils/platformColors'
|
||||
|
||||
const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>()
|
||||
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const platform = computed(() => props.plan.group_platform || '')
|
||||
const isRenewal = computed(() =>
|
||||
props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false
|
||||
)
|
||||
|
||||
// Derived color classes from central config
|
||||
const accentClass = computed(() => platformAccentBarClass(platform.value))
|
||||
const borderClass = computed(() => platformBorderClass(platform.value))
|
||||
const badgeLightClass = computed(() => platformBadgeLightClass(platform.value))
|
||||
const textClass = computed(() => platformTextClass(platform.value))
|
||||
const iconClass = computed(() => platformIconClass(platform.value))
|
||||
const btnClass = computed(() => platformButtonClass(platform.value))
|
||||
const discountClass = computed(() => platformDiscountClass(platform.value))
|
||||
const pLabel = computed(() => platformLabel(platform.value))
|
||||
|
||||
const discountText = computed(() => {
|
||||
if (!props.plan.original_price || props.plan.original_price <= 0) return ''
|
||||
const pct = Math.round((1 - props.plan.price / props.plan.original_price) * 100)
|
||||
return pct > 0 ? `-${pct}%` : ''
|
||||
})
|
||||
|
||||
const rateDisplay = computed(() => {
|
||||
const rate = props.plan.rate_multiplier ?? 1
|
||||
return `×${Number(rate.toPrecision(10))}`
|
||||
})
|
||||
|
||||
const MODEL_SCOPE_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
gemini_text: 'Gemini',
|
||||
gemini_image: 'Imagen',
|
||||
}
|
||||
|
||||
const modelScopeLabels = computed(() => {
|
||||
const scopes = props.plan.supported_model_scopes
|
||||
if (!scopes || scopes.length === 0) return []
|
||||
return scopes.map(s => MODEL_SCOPE_LABELS[s] || s)
|
||||
})
|
||||
|
||||
const validitySuffix = computed(() => {
|
||||
const u = props.plan.validity_unit || 'day'
|
||||
if (u === 'month') return t('payment.perMonth')
|
||||
if (u === 'year') return t('payment.perYear')
|
||||
return `${props.plan.validity_days}${t('payment.days')}`
|
||||
})
|
||||
</script>
|
||||
25
frontend/src/components/payment/ToggleSwitch.vue
Normal file
25
frontend/src/components/payment/ToggleSwitch.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="checked"
|
||||
@click="emit('toggle')"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200',
|
||||
checked ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600',
|
||||
]"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200',
|
||||
checked ? 'translate-x-4' : 'translate-x-0',
|
||||
]" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string; checked: boolean }>()
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
34
frontend/src/components/payment/orderUtils.ts
Normal file
34
frontend/src/components/payment/orderUtils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared utility functions for payment order display.
|
||||
* Used by AdminOrderDetail, AdminOrderTable, AdminRefundDialog, AdminOrdersView, etc.
|
||||
*/
|
||||
|
||||
const STATUS_BADGE_MAP: Record<string, string> = {
|
||||
PENDING: 'badge-warning',
|
||||
PAID: 'badge-info',
|
||||
RECHARGING: 'badge-info',
|
||||
COMPLETED: 'badge-success',
|
||||
EXPIRED: 'badge-secondary',
|
||||
CANCELLED: 'badge-secondary',
|
||||
FAILED: 'badge-danger',
|
||||
REFUND_REQUESTED: 'badge-warning',
|
||||
REFUNDING: 'badge-warning',
|
||||
PARTIALLY_REFUNDED: 'badge-warning',
|
||||
REFUNDED: 'badge-info',
|
||||
REFUND_FAILED: 'badge-danger',
|
||||
}
|
||||
|
||||
const REFUNDABLE_STATUSES = ['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUND_REQUESTED', 'REFUND_FAILED']
|
||||
|
||||
export function statusBadgeClass(status: string): string {
|
||||
return STATUS_BADGE_MAP[status] || 'badge-secondary'
|
||||
}
|
||||
|
||||
export function canRefund(status: string): boolean {
|
||||
return REFUNDABLE_STATUSES.includes(status)
|
||||
}
|
||||
|
||||
export function formatOrderDateTime(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
128
frontend/src/components/payment/providerConfig.ts
Normal file
128
frontend/src/components/payment/providerConfig.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Shared constants and types for payment provider management.
|
||||
*/
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface ConfigFieldDef {
|
||||
key: string
|
||||
label: string
|
||||
sensitive: boolean
|
||||
optional?: boolean
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
export interface TypeOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Callback URL paths for a provider. */
|
||||
export interface CallbackPaths {
|
||||
notifyUrl?: string
|
||||
returnUrl?: string
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
/** Maps provider key → available payment types. */
|
||||
export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
|
||||
easypay: ['alipay', 'wxpay'],
|
||||
alipay: ['alipay'],
|
||||
wxpay: ['wxpay'],
|
||||
stripe: ['card', 'alipay', 'wxpay', 'link'],
|
||||
}
|
||||
|
||||
/** Available payment modes for EasyPay providers. */
|
||||
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
|
||||
|
||||
/** Fixed display order for user-facing payment methods */
|
||||
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe'] as const
|
||||
|
||||
/** Payment mode constants */
|
||||
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
||||
export const PAYMENT_MODE_POPUP = 'popup'
|
||||
|
||||
/** Window features for payment popup windows */
|
||||
export const POPUP_WINDOW_FEATURES = 'width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes'
|
||||
|
||||
/** Wider popup for Stripe redirect methods (Alipay checkout page needs ~1200px) */
|
||||
export const STRIPE_POPUP_WINDOW_FEATURES = 'width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes'
|
||||
|
||||
/** Webhook paths for each provider (relative to origin). */
|
||||
export const WEBHOOK_PATHS: Record<string, string> = {
|
||||
easypay: '/api/v1/payment/webhook/easypay',
|
||||
alipay: '/api/v1/payment/webhook/alipay',
|
||||
wxpay: '/api/v1/payment/webhook/wxpay',
|
||||
stripe: '/api/v1/payment/webhook/stripe',
|
||||
}
|
||||
|
||||
export const RETURN_PATH = '/payment/result'
|
||||
|
||||
/** Fixed callback paths per provider — displayed as read-only after base URL. */
|
||||
export const PROVIDER_CALLBACK_PATHS: Record<string, CallbackPaths> = {
|
||||
easypay: { notifyUrl: WEBHOOK_PATHS.easypay, returnUrl: RETURN_PATH },
|
||||
alipay: { notifyUrl: WEBHOOK_PATHS.alipay, returnUrl: RETURN_PATH },
|
||||
wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay },
|
||||
// stripe: no callback URL config needed (webhook is separate)
|
||||
}
|
||||
|
||||
/** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */
|
||||
export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
|
||||
easypay: [
|
||||
{ key: 'pid', label: 'PID', sensitive: false },
|
||||
{ key: 'pkey', label: 'PKey', sensitive: true },
|
||||
{ key: 'apiBase', label: '', sensitive: false },
|
||||
{ key: 'cidAlipay', label: '', sensitive: false, optional: true },
|
||||
{ key: 'cidWxpay', label: '', sensitive: false, optional: true },
|
||||
],
|
||||
alipay: [
|
||||
{ key: 'appId', label: 'App ID', sensitive: false },
|
||||
{ key: 'privateKey', label: '', sensitive: true },
|
||||
{ key: 'publicKey', label: '', sensitive: true },
|
||||
],
|
||||
wxpay: [
|
||||
{ key: 'appId', label: 'App ID', sensitive: false },
|
||||
{ key: 'mchId', label: '', sensitive: false },
|
||||
{ key: 'privateKey', label: '', sensitive: true },
|
||||
{ key: 'apiV3Key', label: '', sensitive: true },
|
||||
{ key: 'publicKey', label: '', sensitive: true },
|
||||
{ key: 'publicKeyId', label: '', sensitive: false, optional: true },
|
||||
{ key: 'certSerial', label: '', sensitive: false, optional: true },
|
||||
],
|
||||
stripe: [
|
||||
{ key: 'secretKey', label: '', sensitive: true },
|
||||
{ key: 'publishableKey', label: '', sensitive: false },
|
||||
{ key: 'webhookSecret', label: '', sensitive: true },
|
||||
],
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Resolve type label for display. */
|
||||
export function resolveTypeLabel(
|
||||
typeVal: string,
|
||||
_providerKey: string,
|
||||
allTypes: TypeOption[],
|
||||
_redirectLabel: string,
|
||||
): TypeOption {
|
||||
return allTypes.find(pt => pt.value === typeVal) || { value: typeVal, label: typeVal }
|
||||
}
|
||||
|
||||
/** Get available type options for a provider key. */
|
||||
export function getAvailableTypes(
|
||||
providerKey: string,
|
||||
allTypes: TypeOption[],
|
||||
redirectLabel: string,
|
||||
): TypeOption[] {
|
||||
const types = PROVIDER_SUPPORTED_TYPES[providerKey] || []
|
||||
return types.map(t => resolveTypeLabel(t, providerKey, allTypes, redirectLabel))
|
||||
}
|
||||
|
||||
/** Extract base URL from a full callback URL by removing the known path suffix. */
|
||||
export function extractBaseUrl(fullUrl: string, path: string): string {
|
||||
if (!fullUrl) return ''
|
||||
if (fullUrl.endsWith(path)) return fullUrl.slice(0, -path.length)
|
||||
// Fallback: try to extract origin
|
||||
try { return new URL(fullUrl).origin } catch { return fullUrl }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
|
||||
describe('usePersistedPageSize', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
delete window.__APP_CONFIG__
|
||||
})
|
||||
|
||||
it('uses the system table default instead of stale localStorage state', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 1000,
|
||||
table_page_size_options: [20, 50, 1000]
|
||||
} as any
|
||||
localStorage.setItem('table-page-size', '50')
|
||||
localStorage.setItem('table-page-size-source', 'user')
|
||||
|
||||
expect(getPersistedPageSize()).toBe(1000)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,9 @@
|
||||
const STORAGE_KEY = 'table-page-size'
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取/写入 pageSize
|
||||
* 全局共享一个 key,所有表格统一偏好
|
||||
* 读取当前系统配置的表格默认每页条数。
|
||||
* 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
|
||||
*/
|
||||
export function getPersistedPageSize(fallback = DEFAULT_PAGE_SIZE): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = Number(stored)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
} catch {
|
||||
// localStorage 不可用(隐私模式等)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function setPersistedPageSize(size: number): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(size))
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
|
||||
return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, reactive, onUnmounted, toRaw } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { BasePaginationResponse, FetchOptions } from '@/types'
|
||||
import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize'
|
||||
import { getPersistedPageSize } from './usePersistedPageSize'
|
||||
|
||||
interface PaginationState {
|
||||
page: number
|
||||
@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
setPersistedPageSize(size)
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,8 @@ export default {
|
||||
chooseFile: 'Choose File',
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
today: 'Today',
|
||||
tomorrow: 'Tomorrow',
|
||||
unknown: 'Unknown',
|
||||
minutes: 'min',
|
||||
time: {
|
||||
@@ -353,7 +355,11 @@ export default {
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
buySubscription: 'Recharge / Subscription',
|
||||
docs: 'Docs',
|
||||
sora: 'Sora Studio'
|
||||
myOrders: 'My Orders',
|
||||
orderManagement: 'Orders',
|
||||
paymentDashboard: 'Payment Dashboard',
|
||||
paymentConfig: 'Payment Config',
|
||||
paymentPlans: 'Plans'
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -2051,6 +2057,7 @@ export default {
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
unschedulable: 'Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
|
||||
rateLimitedAutoResume: 'Auto resumes in {time}',
|
||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||
@@ -4181,7 +4188,7 @@ export default {
|
||||
gateway: 'Gateway',
|
||||
email: 'Email',
|
||||
backup: 'Backup',
|
||||
data: 'Sora Storage',
|
||||
payment: 'Payment',
|
||||
},
|
||||
emailTabDisabledTitle: 'Email Verification Not Enabled',
|
||||
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
|
||||
@@ -4353,6 +4360,15 @@ export default {
|
||||
apiBaseUrlPlaceholder: 'https://api.example.com',
|
||||
apiBaseUrlHint:
|
||||
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
|
||||
tablePreferencesTitle: 'Global Table Preferences',
|
||||
tablePreferencesDescription: 'Configure default pagination behavior for shared table components',
|
||||
tableDefaultPageSize: 'Default Rows Per Page',
|
||||
tableDefaultPageSizeHint: 'Must be an integer between 5 and 1000',
|
||||
tablePageSizeOptions: 'Rows Per Page Options',
|
||||
tablePageSizeOptionsPlaceholder: '10, 20, 50, 100',
|
||||
tablePageSizeOptionsHint: 'Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save',
|
||||
tableDefaultPageSizeRangeError: 'Default rows per page must be between {min} and {max}',
|
||||
tablePageSizeOptionsFormatError: 'Invalid options format. Enter comma-separated integers between {min} and {max}',
|
||||
customEndpoints: {
|
||||
title: 'Custom Endpoints',
|
||||
description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page',
|
||||
@@ -4425,6 +4441,100 @@ export default {
|
||||
moveUp: 'Move Up',
|
||||
moveDown: 'Move Down',
|
||||
},
|
||||
payment: {
|
||||
title: 'Payment Settings',
|
||||
description: 'Configure payment system options',
|
||||
enabled: 'Enable Payment',
|
||||
enabledHint: 'Enable or disable the payment system',
|
||||
enabledPaymentTypes: 'Enabled Providers',
|
||||
enabledPaymentTypesHint: 'Disabling a provider will also disable its instances',
|
||||
minAmount: 'Minimum Amount',
|
||||
maxAmount: 'Maximum Amount',
|
||||
dailyLimit: 'Daily Limit',
|
||||
orderTimeout: 'Order Timeout',
|
||||
orderTimeoutHint: 'In minutes, minimum 1',
|
||||
maxPendingOrders: 'Max Pending Orders',
|
||||
cancelRateLimit: 'Limit Cancel Rate',
|
||||
cancelRateLimitHint: 'When enabled, users who exceed the cancel limit within the time window cannot create new orders',
|
||||
cancelRateLimitEvery: 'Every',
|
||||
cancelRateLimitAllowMax: 'allow max',
|
||||
cancelRateLimitTimes: 'cancels',
|
||||
cancelRateLimitWindow: 'Window',
|
||||
cancelRateLimitUnit: 'Unit',
|
||||
cancelRateLimitMax: 'Max Cancels',
|
||||
cancelRateLimitUnitMinute: 'Minutes',
|
||||
cancelRateLimitUnitHour: 'Hours',
|
||||
cancelRateLimitUnitDay: 'Days',
|
||||
cancelRateLimitWindowMode: 'Window Mode',
|
||||
cancelRateLimitWindowModeRolling: 'Rolling',
|
||||
cancelRateLimitWindowModeFixed: 'Fixed',
|
||||
helpText: 'Help Text',
|
||||
helpImageUrl: 'Help Image URL',
|
||||
manageProviders: 'Manage Providers',
|
||||
balancePaymentDisabled: 'Disable Balance Recharge',
|
||||
noLimit: 'Empty = no limit',
|
||||
helpImage: 'Help Image',
|
||||
helpImagePlaceholder: 'Upload or enter image URL',
|
||||
helpTextPlaceholder: 'Enter help text...',
|
||||
providerEasypay: 'EasyPay',
|
||||
providerAlipay: 'Alipay (Direct)',
|
||||
providerWxpay: 'WeChat Pay (Direct)',
|
||||
providerStripe: 'Stripe',
|
||||
typeDisabled: 'type disabled',
|
||||
enableTypesFirst: 'Enable at least one payment type above first',
|
||||
easypayRedirect: 'Redirect',
|
||||
paymentMode: 'Payment Mode',
|
||||
modeRedirect: 'Redirect',
|
||||
modeQRCode: 'QR Code',
|
||||
modePopup: 'Popup',
|
||||
validationNameRequired: 'Provider name is required',
|
||||
validationTypesRequired: 'Please select at least one supported payment type',
|
||||
validationFieldRequired: '{field} is required',
|
||||
field_apiBase: 'API Base URL',
|
||||
field_notifyUrl: 'Notify URL',
|
||||
field_returnUrl: 'Return URL',
|
||||
callbackBaseUrl: 'Callback Base URL',
|
||||
field_privateKey: 'Private Key',
|
||||
field_publicKey: 'Public Key',
|
||||
field_mchId: 'Merchant ID',
|
||||
field_apiV3Key: 'API v3 Key',
|
||||
field_publicKeyId: 'Public Key ID',
|
||||
field_certSerial: 'Certificate Serial',
|
||||
field_secretKey: 'Secret Key',
|
||||
field_publishableKey: 'Publishable Key',
|
||||
field_webhookSecret: 'Webhook Secret',
|
||||
field_cid: 'Channel ID',
|
||||
field_cidAlipay: 'Alipay Channel ID',
|
||||
field_cidWxpay: 'WeChat Channel ID',
|
||||
stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:',
|
||||
limitsTitle: 'Limits',
|
||||
limitSingleMin: 'Min per order',
|
||||
limitSingleMax: 'Max per order',
|
||||
limitDaily: 'Daily limit',
|
||||
limitsHint: 'All empty = use global config; partially filled = empty means no limit',
|
||||
limitsUseGlobal: 'Use global',
|
||||
limitsNoLimit: 'No limit',
|
||||
productNamePrefix: 'Product Name Prefix',
|
||||
productNameSuffix: 'Product Name Suffix',
|
||||
preview: 'Preview',
|
||||
loadBalanceStrategy: 'Load Balance Strategy',
|
||||
strategyRoundRobin: 'Round Robin',
|
||||
strategyLeastAmount: 'Least Daily Amount',
|
||||
providerManagement: 'Provider Management',
|
||||
providerManagementDesc: 'Manage payment provider instances',
|
||||
createProvider: 'Add Provider',
|
||||
editProvider: 'Edit Provider',
|
||||
deleteProvider: 'Delete Provider',
|
||||
deleteProviderConfirm: 'Are you sure you want to delete this provider?',
|
||||
providerName: 'Provider Name',
|
||||
providerKey: 'Provider Type',
|
||||
selectProviderKey: 'Select Provider Type',
|
||||
providerConfig: 'Credentials',
|
||||
noProviders: 'No provider instances configured',
|
||||
supportedTypes: 'Supported Payment Types',
|
||||
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
|
||||
refundEnabled: 'Allow Refund',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
description: 'Configure email sending for verification codes',
|
||||
@@ -5074,4 +5184,263 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Payment System
|
||||
payment: {
|
||||
title: 'Recharge / Subscription',
|
||||
amountLabel: 'Amount',
|
||||
quickAmounts: 'Quick Amounts',
|
||||
customAmount: 'Custom Amount',
|
||||
enterAmount: 'Enter amount',
|
||||
paymentMethod: 'Payment Method',
|
||||
fee: 'Fee',
|
||||
actualPay: 'Actual Payment',
|
||||
createOrder: 'Confirm Payment',
|
||||
methods: {
|
||||
easypay: 'EasyPay',
|
||||
alipay: 'Alipay',
|
||||
wxpay: 'WeChat Pay',
|
||||
stripe: 'Stripe',
|
||||
card: 'Card',
|
||||
link: 'Link',
|
||||
alipay_direct: 'Alipay (Direct)',
|
||||
wxpay_direct: 'WeChat Pay (Direct)',
|
||||
},
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
recharging: 'Recharging',
|
||||
completed: 'Completed',
|
||||
expired: 'Expired',
|
||||
cancelled: 'Cancelled',
|
||||
failed: 'Failed',
|
||||
refund_requested: 'Refund Requested',
|
||||
refunding: 'Refunding',
|
||||
refunded: 'Refunded',
|
||||
partially_refunded: 'Partially Refunded',
|
||||
refund_failed: 'Refund Failed',
|
||||
},
|
||||
qr: {
|
||||
scanToPay: 'Scan to Pay',
|
||||
scanAlipay: 'Alipay QR Payment',
|
||||
scanWxpay: 'WeChat QR Payment',
|
||||
scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay',
|
||||
scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay',
|
||||
payInNewWindow: 'Complete Payment in New Window',
|
||||
payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.',
|
||||
openPayWindow: 'Reopen Payment Page',
|
||||
expiresIn: 'Expires in',
|
||||
expired: 'Order Expired',
|
||||
expiredDesc: 'This order has expired. Please create a new one.',
|
||||
cancelled: 'Order Cancelled',
|
||||
cancelledDesc: 'You have cancelled this payment.',
|
||||
waitingPayment: 'Waiting for payment...',
|
||||
cancelOrder: 'Cancel Order',
|
||||
},
|
||||
orders: {
|
||||
title: 'My Orders',
|
||||
empty: 'No orders yet',
|
||||
orderId: 'Order ID',
|
||||
orderNo: 'Order No.',
|
||||
amount: 'Amount',
|
||||
payAmount: 'Paid',
|
||||
status: 'Status',
|
||||
paymentMethod: 'Payment Method',
|
||||
createdAt: 'Created',
|
||||
cancel: 'Cancel Order',
|
||||
userId: 'User ID',
|
||||
orderType: 'Order Type',
|
||||
actions: 'Actions',
|
||||
requestRefund: 'Request Refund',
|
||||
},
|
||||
result: {
|
||||
success: 'Payment Successful',
|
||||
subscriptionSuccess: 'Subscription Successful',
|
||||
failed: 'Payment Failed',
|
||||
backToRecharge: 'Back to Recharge',
|
||||
viewOrders: 'View Orders',
|
||||
},
|
||||
currentBalance: 'Current Balance',
|
||||
rechargeAccount: 'Recharge Account',
|
||||
activeSubscription: 'Active Subscription',
|
||||
noActiveSubscription: 'No active subscription',
|
||||
tabTopUp: 'Top Up',
|
||||
tabSubscribe: 'Subscribe',
|
||||
noPlans: 'No subscription plans available',
|
||||
notAvailable: 'Top-up is currently unavailable',
|
||||
confirmSubscription: 'Confirm Subscription',
|
||||
confirmCancel: 'Are you sure you want to cancel this order?',
|
||||
amountTooLow: 'Minimum amount is {min}',
|
||||
amountTooHigh: 'Maximum amount is {max}',
|
||||
amountNoMethod: 'No payment method available for this amount',
|
||||
refundReason: 'Refund Reason',
|
||||
refundReasonPlaceholder: 'Please describe your refund reason',
|
||||
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
|
||||
stripeMissingParams: 'Missing order ID or client secret',
|
||||
stripeNotConfigured: 'Stripe is not configured',
|
||||
errors: {
|
||||
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
cancelRateLimited: 'Too many cancellations. Please try again later.',
|
||||
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
|
||||
},
|
||||
stripePay: 'Pay Now',
|
||||
stripeSuccessProcessing: 'Payment successful, processing your order...',
|
||||
stripePopup: {
|
||||
redirecting: 'Redirecting to payment page...',
|
||||
loadingQr: 'Loading WeChat Pay QR code...',
|
||||
timeout: 'Timed out waiting for payment credentials, please retry',
|
||||
qrFailed: 'Failed to get WeChat Pay QR code',
|
||||
},
|
||||
subscribeNow: 'Subscribe Now',
|
||||
renewNow: 'Renew',
|
||||
selectPlan: 'Select Plan',
|
||||
planFeatures: 'Features',
|
||||
planCard: {
|
||||
rate: 'Rate',
|
||||
dailyLimit: 'Daily',
|
||||
weeklyLimit: 'Weekly',
|
||||
monthlyLimit: 'Monthly',
|
||||
quota: 'Quota',
|
||||
unlimited: 'Unlimited',
|
||||
models: 'Models',
|
||||
},
|
||||
days: 'days',
|
||||
months: 'months',
|
||||
years: 'years',
|
||||
oneMonth: '1 Month',
|
||||
oneYear: '1 Year',
|
||||
perMonth: 'month',
|
||||
perYear: 'year',
|
||||
admin: {
|
||||
tabs: {
|
||||
overview: 'Overview',
|
||||
orders: 'Orders',
|
||||
channels: 'Channels',
|
||||
plans: 'Plans',
|
||||
},
|
||||
todayRevenue: 'Today Revenue',
|
||||
totalRevenue: 'Total Revenue',
|
||||
todayOrders: 'Today Orders',
|
||||
orderCount: 'Order Count',
|
||||
avgAmount: 'Average Amount',
|
||||
revenue: 'Revenue',
|
||||
dailyRevenue: 'Daily Revenue',
|
||||
paymentDistribution: 'Payment Distribution',
|
||||
colUser: 'User',
|
||||
topUsers: 'Top Users',
|
||||
noData: 'No data',
|
||||
days: 'days',
|
||||
weeks: 'weeks',
|
||||
months: 'months',
|
||||
searchOrders: 'Search orders...',
|
||||
allStatuses: 'All Statuses',
|
||||
allPaymentTypes: 'All Payment Types',
|
||||
allOrderTypes: 'All Order Types',
|
||||
orderDetail: 'Order Detail',
|
||||
orderType: 'Order Type',
|
||||
orders: 'Orders',
|
||||
balanceOrder: 'Balance Top-Up',
|
||||
subscriptionOrder: 'Subscription',
|
||||
paidAt: 'Paid At',
|
||||
completedAt: 'Completed At',
|
||||
expiresAt: 'Expires At',
|
||||
feeRate: 'Fee Rate',
|
||||
refund: 'Refund',
|
||||
refundOrder: 'Refund Order',
|
||||
refundAmount: 'Refund Amount',
|
||||
maxRefundable: 'Max Refundable',
|
||||
refundReason: 'Refund Reason',
|
||||
refundReasonPlaceholder: 'Please enter refund reason',
|
||||
confirmRefund: 'Confirm Refund',
|
||||
refundSuccess: 'Refund successful',
|
||||
refundInfo: 'Refund Info',
|
||||
refundEnabled: 'Refund Enabled',
|
||||
alreadyRefunded: 'Already Refunded',
|
||||
deductBalance: 'Deduct Balance',
|
||||
deductBalanceHint: 'Subtract recharged amount from user balance',
|
||||
userBalance: 'User Balance',
|
||||
orderAmount: 'Order Amount',
|
||||
insufficientBalance: 'Insufficient balance — will deduct to $0',
|
||||
noDeduction: 'Will NOT deduct user balance',
|
||||
forceRefund: 'Force refund (ignore balance check)',
|
||||
orderCancelled: 'Order Cancelled',
|
||||
retry: 'Retry',
|
||||
retrySuccess: 'Retry successful',
|
||||
approveRefund: 'Approve Refund',
|
||||
retryRefund: 'Retry Refund',
|
||||
refundRequestInfo: 'Refund Request Info',
|
||||
refundRequestedAt: 'Requested At',
|
||||
refundRequestedBy: 'Requested By',
|
||||
refundRequestReason: 'Request Reason',
|
||||
auditLogs: 'Audit Logs',
|
||||
operator: 'Operator',
|
||||
channelName: 'Channel Name',
|
||||
channelDescription: 'Channel Description',
|
||||
createChannel: 'Create Channel',
|
||||
editChannel: 'Edit Channel',
|
||||
deleteChannel: 'Delete Channel',
|
||||
deleteChannelConfirm: 'Are you sure you want to delete this channel?',
|
||||
planName: 'Plan Name',
|
||||
planDescription: 'Plan Description',
|
||||
createPlan: 'Create Plan',
|
||||
editPlan: 'Edit Plan',
|
||||
deletePlan: 'Delete Plan',
|
||||
deletePlanConfirm: 'Are you sure you want to delete this plan?',
|
||||
originalPrice: 'Original Price',
|
||||
price: 'Price',
|
||||
validityDays: 'Validity (days)',
|
||||
validityUnit: 'Validity Unit',
|
||||
sortOrder: 'Sort Order',
|
||||
forSale: 'For Sale',
|
||||
onSale: 'On Sale',
|
||||
offSale: 'Off Sale',
|
||||
group: 'Group',
|
||||
groupId: 'Group ID',
|
||||
features: 'Features',
|
||||
featuresHint: 'One feature per line',
|
||||
featuresPlaceholder: 'Enter plan features...',
|
||||
providerManagement: 'Provider Management',
|
||||
providerManagementDesc: 'Manage payment provider instances',
|
||||
createProvider: 'Create Provider',
|
||||
editProvider: 'Edit Provider',
|
||||
deleteProvider: 'Delete Provider',
|
||||
deleteProviderConfirm: 'Are you sure you want to delete this provider?',
|
||||
providerName: 'Provider Name',
|
||||
providerKey: 'Provider Key',
|
||||
selectProviderKey: 'Select Provider Key',
|
||||
providerConfig: 'Provider Config',
|
||||
noProviders: 'No providers configured',
|
||||
noProvidersHint: 'Create a provider instance to start accepting payments',
|
||||
supportedTypes: 'Supported Payment Types',
|
||||
supportedTypesHint: 'Select the payment types this provider supports',
|
||||
rateMultiplier: 'Rate Multiplier',
|
||||
dashboardTitle: 'Payment Dashboard',
|
||||
dashboardDesc: 'Recharge order analytics and insights',
|
||||
daySuffix: 'd',
|
||||
paymentConfigTitle: 'Payment Config',
|
||||
paymentConfigDesc: 'Configure payment providers and settings',
|
||||
plansPageTitle: 'Subscription Plans',
|
||||
plansPageDesc: 'Manage subscription plan configuration',
|
||||
tabPlanConfig: 'Plan Configuration',
|
||||
tabUserSubs: 'User Subscriptions',
|
||||
selectGroup: 'Select a group',
|
||||
groupMissing: 'Missing',
|
||||
groupInfo: 'Group Info',
|
||||
platform: 'Platform',
|
||||
rateMultiplierLabel: 'Rate',
|
||||
dailyLimit: 'Daily Limit',
|
||||
weeklyLimit: 'Weekly Limit',
|
||||
monthlyLimit: 'Monthly Limit',
|
||||
unlimited: 'Unlimited',
|
||||
searchUserSubs: 'Search user subscriptions...',
|
||||
daily: 'D',
|
||||
weekly: 'W',
|
||||
monthly: 'M',
|
||||
subsStatus: {
|
||||
active: 'Active',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -308,6 +308,8 @@ export default {
|
||||
chooseFile: '选择文件',
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
today: '今天',
|
||||
tomorrow: '明天',
|
||||
unknown: '未知',
|
||||
minutes: '分钟',
|
||||
time: {
|
||||
@@ -353,7 +355,11 @@ export default {
|
||||
mySubscriptions: '我的订阅',
|
||||
buySubscription: '充值/订阅',
|
||||
docs: '文档',
|
||||
sora: 'Sora 创作'
|
||||
myOrders: '我的订单',
|
||||
orderManagement: '订单管理',
|
||||
paymentDashboard: '支付概览',
|
||||
paymentConfig: '支付配置',
|
||||
paymentPlans: '订阅套餐'
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -2234,6 +2240,7 @@ export default {
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
unschedulable: '不可调度',
|
||||
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
|
||||
rateLimitedAutoResume: '{time} 自动恢复',
|
||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||
@@ -4346,7 +4353,7 @@ export default {
|
||||
gateway: '网关服务',
|
||||
email: '邮件设置',
|
||||
backup: '数据备份',
|
||||
data: 'Sora 存储',
|
||||
payment: '支付设置',
|
||||
},
|
||||
emailTabDisabledTitle: '邮箱验证未启用',
|
||||
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
|
||||
@@ -4514,6 +4521,15 @@ export default {
|
||||
apiBaseUrl: 'API 端点地址',
|
||||
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
|
||||
apiBaseUrlPlaceholder: 'https://api.example.com',
|
||||
tablePreferencesTitle: '通用表格设置',
|
||||
tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为',
|
||||
tableDefaultPageSize: '默认每页条数',
|
||||
tableDefaultPageSizeHint: '必须为 5-1000 之间的整数',
|
||||
tablePageSizeOptions: '可选每页条数列表',
|
||||
tablePageSizeOptionsPlaceholder: '10, 20, 50, 100',
|
||||
tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序',
|
||||
tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间',
|
||||
tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔',
|
||||
customEndpoints: {
|
||||
title: '自定义端点',
|
||||
description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制',
|
||||
@@ -4589,6 +4605,100 @@ export default {
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
},
|
||||
payment: {
|
||||
title: '支付设置',
|
||||
description: '配置支付系统选项',
|
||||
enabled: '启用支付',
|
||||
enabledHint: '启用或禁用支付系统',
|
||||
enabledPaymentTypes: '启用的服务商',
|
||||
enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例',
|
||||
minAmount: '最低金额',
|
||||
maxAmount: '最高金额',
|
||||
dailyLimit: '每日限额',
|
||||
orderTimeout: '订单超时时间',
|
||||
orderTimeoutHint: '单位:分钟,至少 1 分钟',
|
||||
maxPendingOrders: '最大待支付订单数',
|
||||
cancelRateLimit: '限制取消频率',
|
||||
cancelRateLimitHint: '启用后,用户在时间窗口内取消订单次数超限将无法创建新订单',
|
||||
cancelRateLimitEvery: '每',
|
||||
cancelRateLimitAllowMax: '最多',
|
||||
cancelRateLimitTimes: '次',
|
||||
cancelRateLimitWindow: '时间窗口',
|
||||
cancelRateLimitUnit: '周期',
|
||||
cancelRateLimitMax: '最大取消次数',
|
||||
cancelRateLimitUnitMinute: '分钟',
|
||||
cancelRateLimitUnitHour: '小时',
|
||||
cancelRateLimitUnitDay: '天',
|
||||
cancelRateLimitWindowMode: '窗口模式',
|
||||
cancelRateLimitWindowModeRolling: '滚动',
|
||||
cancelRateLimitWindowModeFixed: '固定',
|
||||
helpText: '帮助文本',
|
||||
helpImageUrl: '帮助图片链接',
|
||||
manageProviders: '管理服务商',
|
||||
balancePaymentDisabled: '禁用余额充值',
|
||||
noLimit: '留空表示不限制',
|
||||
helpImage: '帮助图片',
|
||||
helpImagePlaceholder: '上传或输入图片链接',
|
||||
helpTextPlaceholder: '输入帮助说明文本...',
|
||||
providerEasypay: '易支付',
|
||||
providerAlipay: '支付宝官方',
|
||||
providerWxpay: '微信官方',
|
||||
providerStripe: 'Stripe',
|
||||
typeDisabled: '类型已禁用',
|
||||
enableTypesFirst: '请先在上方启用至少一种服务商',
|
||||
easypayRedirect: '跳转',
|
||||
paymentMode: '支付模式',
|
||||
modeRedirect: '跳转',
|
||||
modeQRCode: '二维码',
|
||||
modePopup: '弹窗',
|
||||
validationNameRequired: '服务商名称不能为空',
|
||||
validationTypesRequired: '请至少选择一种支持的支付方式',
|
||||
validationFieldRequired: '{field} 不能为空',
|
||||
field_apiBase: 'API 基础地址',
|
||||
field_notifyUrl: '异步通知地址',
|
||||
field_returnUrl: '同步跳转地址',
|
||||
callbackBaseUrl: '回调基础地址',
|
||||
field_privateKey: '私钥',
|
||||
field_publicKey: '公钥',
|
||||
field_mchId: '商户号',
|
||||
field_apiV3Key: 'API v3 密钥',
|
||||
field_publicKeyId: '公钥 ID',
|
||||
field_certSerial: '证书序列号',
|
||||
field_secretKey: '密钥',
|
||||
field_publishableKey: '公开密钥',
|
||||
field_webhookSecret: 'Webhook 密钥',
|
||||
field_cid: '支付渠道 ID',
|
||||
field_cidAlipay: '支付宝渠道 ID',
|
||||
field_cidWxpay: '微信渠道 ID',
|
||||
stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:',
|
||||
limitsTitle: '限额配置',
|
||||
limitSingleMin: '单笔最低',
|
||||
limitSingleMax: '单笔最高',
|
||||
limitDaily: '每日限额',
|
||||
limitsHint: '全部留空使用全局配置,部分填写时留空项表示不限制',
|
||||
limitsUseGlobal: '使用全局配置',
|
||||
limitsNoLimit: '不限制',
|
||||
productNamePrefix: '商品名前缀',
|
||||
productNameSuffix: '商品名后缀',
|
||||
preview: '预览',
|
||||
loadBalanceStrategy: '负载均衡策略',
|
||||
strategyRoundRobin: '轮询',
|
||||
strategyLeastAmount: '最少金额',
|
||||
providerManagement: '服务商管理',
|
||||
providerManagementDesc: '管理支付服务商实例',
|
||||
createProvider: '添加服务商',
|
||||
editProvider: '编辑服务商',
|
||||
deleteProvider: '删除服务商',
|
||||
deleteProviderConfirm: '确定要删除此服务商吗?',
|
||||
providerName: '服务商名称',
|
||||
providerKey: '服务商类型',
|
||||
selectProviderKey: '选择服务商类型',
|
||||
providerConfig: '凭证配置',
|
||||
noProviders: '暂无服务商实例',
|
||||
supportedTypes: '支持的支付方式',
|
||||
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
|
||||
refundEnabled: '允许退款',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
description: '配置用于发送验证码的邮件服务',
|
||||
@@ -5262,4 +5372,263 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Payment System
|
||||
payment: {
|
||||
title: '充值/订阅',
|
||||
amountLabel: '充值金额',
|
||||
quickAmounts: '快捷金额',
|
||||
customAmount: '自定义金额',
|
||||
enterAmount: '输入金额',
|
||||
paymentMethod: '支付方式',
|
||||
fee: '手续费',
|
||||
actualPay: '实付金额',
|
||||
createOrder: '确认支付',
|
||||
methods: {
|
||||
easypay: '易支付',
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信支付',
|
||||
stripe: 'Stripe',
|
||||
card: '银行卡',
|
||||
link: 'Link',
|
||||
alipay_direct: '支付宝(直连)',
|
||||
wxpay_direct: '微信支付(直连)',
|
||||
},
|
||||
status: {
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
recharging: '充值中',
|
||||
completed: '已完成',
|
||||
expired: '已过期',
|
||||
cancelled: '已取消',
|
||||
failed: '失败',
|
||||
refund_requested: '退款申请中',
|
||||
refunding: '退款中',
|
||||
refunded: '已退款',
|
||||
partially_refunded: '部分退款',
|
||||
refund_failed: '退款失败',
|
||||
},
|
||||
qr: {
|
||||
scanToPay: '请扫码支付',
|
||||
scanAlipay: '支付宝扫码支付',
|
||||
scanWxpay: '微信扫码支付',
|
||||
scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付',
|
||||
scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付',
|
||||
payInNewWindow: '请在新窗口中完成支付',
|
||||
payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面',
|
||||
openPayWindow: '重新打开支付页面',
|
||||
expiresIn: '剩余支付时间',
|
||||
expired: '订单已过期',
|
||||
expiredDesc: '订单已超时,请重新创建订单',
|
||||
cancelled: '订单已取消',
|
||||
cancelledDesc: '您已取消本次支付',
|
||||
waitingPayment: '等待支付...',
|
||||
cancelOrder: '取消订单',
|
||||
},
|
||||
orders: {
|
||||
title: '我的订单',
|
||||
empty: '暂无订单',
|
||||
orderId: '订单 ID',
|
||||
orderNo: '订单编号',
|
||||
amount: '金额',
|
||||
payAmount: '实付',
|
||||
status: '状态',
|
||||
paymentMethod: '支付方式',
|
||||
createdAt: '创建时间',
|
||||
cancel: '取消订单',
|
||||
userId: '用户 ID',
|
||||
orderType: '订单类型',
|
||||
actions: '操作',
|
||||
requestRefund: '申请退款',
|
||||
},
|
||||
result: {
|
||||
success: '支付成功',
|
||||
subscriptionSuccess: '订阅成功',
|
||||
failed: '支付失败',
|
||||
backToRecharge: '返回充值',
|
||||
viewOrders: '查看订单',
|
||||
},
|
||||
currentBalance: '当前余额',
|
||||
rechargeAccount: '充值账户',
|
||||
activeSubscription: '当前订阅',
|
||||
noActiveSubscription: '暂无有效订阅',
|
||||
tabTopUp: '充值',
|
||||
tabSubscribe: '订阅',
|
||||
noPlans: '暂无可用订阅套餐',
|
||||
notAvailable: '充值功能暂未开放',
|
||||
confirmSubscription: '确认订阅',
|
||||
confirmCancel: '确定要取消此订单吗?',
|
||||
amountTooLow: '最低金额为 {min}',
|
||||
amountTooHigh: '最高金额为 {max}',
|
||||
amountNoMethod: '该金额没有可用的支付方式',
|
||||
refundReason: '退款原因',
|
||||
refundReasonPlaceholder: '请描述您的退款原因',
|
||||
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
|
||||
stripeMissingParams: '缺少订单ID或支付密钥',
|
||||
stripeNotConfigured: 'Stripe 未配置',
|
||||
errors: {
|
||||
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
cancelRateLimited: '取消订单过于频繁,请稍后再试',
|
||||
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
|
||||
},
|
||||
stripePay: '立即支付',
|
||||
stripeSuccessProcessing: '支付成功,正在处理订单...',
|
||||
stripePopup: {
|
||||
redirecting: '正在跳转到支付页面...',
|
||||
loadingQr: '正在获取微信支付二维码...',
|
||||
timeout: '等待支付凭证超时,请重试',
|
||||
qrFailed: '未能获取微信支付二维码',
|
||||
},
|
||||
subscribeNow: '立即开通',
|
||||
renewNow: '续费',
|
||||
selectPlan: '选择套餐',
|
||||
planFeatures: '功能特性',
|
||||
planCard: {
|
||||
rate: '倍率',
|
||||
dailyLimit: '日限额',
|
||||
weeklyLimit: '周限额',
|
||||
monthlyLimit: '月限额',
|
||||
quota: '配额',
|
||||
unlimited: '无限制',
|
||||
models: '模型',
|
||||
},
|
||||
days: '天',
|
||||
months: '个月',
|
||||
years: '年',
|
||||
oneMonth: '1 个月',
|
||||
oneYear: '1 年',
|
||||
perMonth: '月',
|
||||
perYear: '年',
|
||||
admin: {
|
||||
tabs: {
|
||||
overview: '概览',
|
||||
orders: '订单管理',
|
||||
channels: '支付渠道',
|
||||
plans: '订阅套餐',
|
||||
},
|
||||
todayRevenue: '今日收入',
|
||||
totalRevenue: '总收入',
|
||||
todayOrders: '今日订单',
|
||||
orderCount: '订单数',
|
||||
avgAmount: '平均金额',
|
||||
revenue: '收入',
|
||||
dailyRevenue: '每日收入',
|
||||
paymentDistribution: '支付方式分布',
|
||||
colUser: '用户',
|
||||
topUsers: '消费排行',
|
||||
noData: '暂无数据',
|
||||
days: '天',
|
||||
weeks: '周',
|
||||
months: '月',
|
||||
searchOrders: '搜索订单...',
|
||||
allStatuses: '全部状态',
|
||||
allPaymentTypes: '全部支付方式',
|
||||
allOrderTypes: '全部订单类型',
|
||||
orderDetail: '订单详情',
|
||||
orderType: '订单类型',
|
||||
orders: '订单',
|
||||
balanceOrder: '余额充值',
|
||||
subscriptionOrder: '订阅',
|
||||
paidAt: '支付时间',
|
||||
completedAt: '完成时间',
|
||||
expiresAt: '过期时间',
|
||||
feeRate: '手续费率',
|
||||
refund: '退款',
|
||||
refundOrder: '退款订单',
|
||||
refundAmount: '退款金额',
|
||||
maxRefundable: '最大可退金额',
|
||||
refundReason: '退款原因',
|
||||
refundReasonPlaceholder: '请输入退款原因',
|
||||
confirmRefund: '确认退款',
|
||||
refundSuccess: '退款成功',
|
||||
refundInfo: '退款信息',
|
||||
refundEnabled: '允许退款',
|
||||
alreadyRefunded: '已退款',
|
||||
deductBalance: '扣除余额',
|
||||
deductBalanceHint: '从用户余额中扣回充值金额',
|
||||
userBalance: '用户余额',
|
||||
orderAmount: '订单金额',
|
||||
insufficientBalance: '余额不足,将扣至 $0',
|
||||
noDeduction: '将不扣除用户余额',
|
||||
forceRefund: '强制退款(忽略余额检查)',
|
||||
orderCancelled: '订单已取消',
|
||||
retry: '重试',
|
||||
retrySuccess: '重试成功',
|
||||
approveRefund: '批准退款',
|
||||
retryRefund: '重试退款',
|
||||
refundRequestInfo: '退款申请信息',
|
||||
refundRequestedAt: '申请时间',
|
||||
refundRequestedBy: '申请人',
|
||||
refundRequestReason: '申请原因',
|
||||
auditLogs: '操作日志',
|
||||
operator: '操作人',
|
||||
channelName: '渠道名称',
|
||||
channelDescription: '渠道描述',
|
||||
createChannel: '创建渠道',
|
||||
editChannel: '编辑渠道',
|
||||
deleteChannel: '删除渠道',
|
||||
deleteChannelConfirm: '确定要删除此渠道吗?',
|
||||
planName: '套餐名称',
|
||||
planDescription: '套餐描述',
|
||||
createPlan: '创建套餐',
|
||||
editPlan: '编辑套餐',
|
||||
deletePlan: '删除套餐',
|
||||
deletePlanConfirm: '确定要删除此套餐吗?',
|
||||
originalPrice: '原价',
|
||||
price: '价格',
|
||||
validityDays: '有效期(天)',
|
||||
validityUnit: '有效期单位',
|
||||
sortOrder: '排序',
|
||||
forSale: '上架状态',
|
||||
onSale: '上架',
|
||||
offSale: '下架',
|
||||
group: '分组',
|
||||
groupId: '分组 ID',
|
||||
features: '功能特性',
|
||||
featuresHint: '每行一个特性',
|
||||
featuresPlaceholder: '输入套餐特性...',
|
||||
providerManagement: '服务商管理',
|
||||
providerManagementDesc: '管理支付服务商实例',
|
||||
createProvider: '创建服务商',
|
||||
editProvider: '编辑服务商',
|
||||
deleteProvider: '删除服务商',
|
||||
deleteProviderConfirm: '确定要删除此服务商吗?',
|
||||
providerName: '服务商名称',
|
||||
providerKey: '服务商标识',
|
||||
selectProviderKey: '选择服务商标识',
|
||||
providerConfig: '服务商配置',
|
||||
noProviders: '暂无服务商',
|
||||
noProvidersHint: '创建一个服务商实例以开始接受支付',
|
||||
supportedTypes: '支持的支付方式',
|
||||
supportedTypesHint: '选择此服务商支持的支付方式',
|
||||
rateMultiplier: '费率倍数',
|
||||
dashboardTitle: '支付概览',
|
||||
dashboardDesc: '充值订单统计与分析',
|
||||
daySuffix: '天',
|
||||
paymentConfigTitle: '支付配置',
|
||||
paymentConfigDesc: '管理支付服务商与相关设置',
|
||||
plansPageTitle: '订阅套餐管理',
|
||||
plansPageDesc: '管理订阅套餐配置',
|
||||
tabPlanConfig: '套餐配置',
|
||||
tabUserSubs: '用户订阅',
|
||||
selectGroup: '请选择分组',
|
||||
groupMissing: '缺失',
|
||||
groupInfo: '分组信息',
|
||||
platform: '平台',
|
||||
rateMultiplierLabel: '倍率',
|
||||
dailyLimit: '日限额',
|
||||
weeklyLimit: '周限额',
|
||||
monthlyLimit: '月限额',
|
||||
unlimited: '无限制',
|
||||
searchUserSubs: '搜索用户订阅...',
|
||||
daily: '日',
|
||||
weekly: '周',
|
||||
monthly: '月',
|
||||
subsStatus: {
|
||||
active: '生效中',
|
||||
expired: '已过期',
|
||||
revoked: '已撤销',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -201,13 +201,73 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/purchase',
|
||||
name: 'PurchaseSubscription',
|
||||
component: () => import('@/views/user/PurchaseSubscriptionView.vue'),
|
||||
component: () => import('@/views/user/PaymentView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Purchase Subscription',
|
||||
titleKey: 'purchase.title',
|
||||
descriptionKey: 'purchase.description'
|
||||
titleKey: 'nav.buySubscription',
|
||||
descriptionKey: 'purchase.description',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'OrderList',
|
||||
component: () => import('@/views/user/UserOrdersView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'My Orders',
|
||||
titleKey: 'nav.myOrders',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payment/qrcode',
|
||||
name: 'PaymentQRCode',
|
||||
component: () => import('@/views/user/PaymentQRCodeView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Payment',
|
||||
titleKey: 'payment.qr.scanToPay',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payment/result',
|
||||
name: 'PaymentResult',
|
||||
component: () => import('@/views/user/PaymentResultView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
requiresAdmin: false,
|
||||
title: 'Payment Result',
|
||||
titleKey: 'payment.result.success',
|
||||
requiresPayment: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payment/stripe',
|
||||
name: 'StripePayment',
|
||||
component: () => import('@/views/user/StripePaymentView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Stripe Payment',
|
||||
titleKey: 'payment.stripePay',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payment/stripe-popup',
|
||||
name: 'StripePopup',
|
||||
component: () => import('@/views/user/StripePopupView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Payment',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -384,6 +444,45 @@ const routes: RouteRecordRaw[] = [
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ==================== Payment Admin Routes ====================
|
||||
{
|
||||
path: '/admin/orders/dashboard',
|
||||
name: 'AdminPaymentDashboard',
|
||||
component: () => import('@/views/admin/orders/AdminPaymentDashboardView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Payment Dashboard',
|
||||
titleKey: 'nav.paymentDashboard',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/orders',
|
||||
name: 'AdminOrders',
|
||||
component: () => import('@/views/admin/orders/AdminOrdersView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Order Management',
|
||||
titleKey: 'nav.orderManagement',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/orders/plans',
|
||||
name: 'AdminPaymentPlans',
|
||||
component: () => import('@/views/admin/orders/AdminPaymentPlansView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Subscription Plans',
|
||||
titleKey: 'nav.paymentPlans',
|
||||
requiresPayment: true
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 404 Not Found ====================
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
@@ -500,6 +599,16 @@ router.beforeEach((to, _from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check payment requirement (internal payment system only)
|
||||
if (to.meta.requiresPayment) {
|
||||
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
|
||||
if (!paymentEnabled) {
|
||||
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 简易模式下限制访问某些页面
|
||||
if (authStore.isSimpleMode) {
|
||||
const restrictedPaths = [
|
||||
|
||||
16
frontend/src/router/meta.d.ts
vendored
16
frontend/src/router/meta.d.ts
vendored
@@ -42,5 +42,21 @@ declare module 'vue-router' {
|
||||
* @default false
|
||||
*/
|
||||
hideInMenu?: boolean
|
||||
|
||||
/**
|
||||
* Whether this route requires internal payment system to be enabled
|
||||
* @default false
|
||||
*/
|
||||
requiresPayment?: boolean
|
||||
|
||||
/**
|
||||
* i18n key for the page title
|
||||
*/
|
||||
titleKey?: string
|
||||
|
||||
/**
|
||||
* i18n key for the page description
|
||||
*/
|
||||
descriptionKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
|
||||
// Mock API 模块
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
@@ -15,12 +16,14 @@ describe('useAppStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
localStorage.clear()
|
||||
// 清除 window.__APP_CONFIG__
|
||||
delete (window as any).__APP_CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// --- Toast 消息管理 ---
|
||||
@@ -291,5 +294,43 @@ describe('useAppStore', () => {
|
||||
expect(store.publicSettingsLoaded).toBe(false)
|
||||
expect(store.cachedPublicSettings).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchPublicSettings(force) 会同步更新运行时注入配置', async () => {
|
||||
vi.mocked(getPublicSettings).mockResolvedValue({
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
registration_email_suffix_whitelist: [],
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
invitation_code_enabled: false,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
site_name: 'Updated Site',
|
||||
site_logo: '',
|
||||
site_subtitle: '',
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
table_default_page_size: 1000,
|
||||
table_page_size_options: [20, 100, 1000],
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
version: '1.0.0'
|
||||
})
|
||||
|
||||
const store = useAppStore()
|
||||
await store.fetchPublicSettings(true)
|
||||
|
||||
expect((window as any).__APP_CONFIG__.table_default_page_size).toBe(1000)
|
||||
expect((window as any).__APP_CONFIG__.table_page_size_options).toEqual([20, 100, 1000])
|
||||
expect(localStorage.getItem('table-page-size')).toBeNull()
|
||||
expect(localStorage.getItem('table-page-size-source')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
||||
const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true))
|
||||
const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true))
|
||||
const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto'))
|
||||
const paymentEnabled = ref(readCachedBool('payment_enabled_cached', false))
|
||||
const customMenuItems = ref<CustomMenuItem[]>([])
|
||||
|
||||
async function fetch(force = false): Promise<void> {
|
||||
@@ -56,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const settings = await adminAPI.settings.getSettings()
|
||||
const [settings, paymentConfigResp] = await Promise.all([
|
||||
adminAPI.settings.getSettings(),
|
||||
adminAPI.payment.getConfig()
|
||||
])
|
||||
opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true
|
||||
writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value)
|
||||
|
||||
@@ -68,6 +72,9 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
||||
|
||||
customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : []
|
||||
|
||||
paymentEnabled.value = paymentConfigResp.data?.enabled ?? false
|
||||
writeCachedBool('payment_enabled_cached', paymentEnabled.value)
|
||||
|
||||
loaded.value = true
|
||||
} catch (err) {
|
||||
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
|
||||
@@ -90,6 +97,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function setPaymentEnabledLocal(value: boolean) {
|
||||
paymentEnabled.value = value
|
||||
writeCachedBool('payment_enabled_cached', value)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function setOpsQueryModeDefaultLocal(value: string) {
|
||||
opsQueryModeDefault.value = value || 'auto'
|
||||
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
|
||||
@@ -126,10 +139,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
|
||||
opsMonitoringEnabled,
|
||||
opsRealtimeMonitoringEnabled,
|
||||
opsQueryModeDefault,
|
||||
paymentEnabled,
|
||||
customMenuItems,
|
||||
fetch,
|
||||
setOpsMonitoringEnabledLocal,
|
||||
setOpsRealtimeMonitoringEnabledLocal,
|
||||
setPaymentEnabledLocal,
|
||||
setOpsQueryModeDefaultLocal
|
||||
}
|
||||
})
|
||||
|
||||
@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
* Apply settings to store state (internal helper to avoid code duplication)
|
||||
*/
|
||||
function applySettings(config: PublicSettings): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__APP_CONFIG__ = { ...config }
|
||||
}
|
||||
cachedPublicSettings.value = config
|
||||
siteName.value = config.site_name || 'Sub2API'
|
||||
siteLogo.value = config.site_logo || ''
|
||||
@@ -327,14 +330,14 @@ export const useAppStore = defineStore('app', () => {
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
payment_enabled: false,
|
||||
table_default_page_size: 20,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
oidc_oauth_enabled: false,
|
||||
oidc_oauth_provider_name: 'OIDC',
|
||||
sora_client_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export { useAdminSettingsStore } from './adminSettings'
|
||||
export { useSubscriptionStore } from './subscriptions'
|
||||
export { useOnboardingStore } from './onboarding'
|
||||
export { useAnnouncementStore } from './announcements'
|
||||
export { usePaymentStore } from './payment'
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
101
frontend/src/stores/payment.ts
Normal file
101
frontend/src/stores/payment.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Payment Store
|
||||
* Manages payment configuration, current order state, and subscription plans
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import type { PaymentConfig, PaymentOrder, SubscriptionPlan, CreateOrderRequest } from '@/types/payment'
|
||||
|
||||
export const usePaymentStore = defineStore('payment', () => {
|
||||
// ==================== State ====================
|
||||
|
||||
/** Payment configuration from backend */
|
||||
const config = ref<PaymentConfig | null>(null)
|
||||
/** Currently active order (for payment flow) */
|
||||
const currentOrder = ref<PaymentOrder | null>(null)
|
||||
/** Available subscription plans */
|
||||
const plans = ref<SubscriptionPlan[]>([])
|
||||
|
||||
const configLoading = ref(false)
|
||||
const configLoaded = ref(false)
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/** Fetch payment configuration */
|
||||
async function fetchConfig(force = false): Promise<PaymentConfig | null> {
|
||||
if (configLoaded.value && !force) return config.value
|
||||
if (configLoading.value) return config.value
|
||||
|
||||
configLoading.value = true
|
||||
try {
|
||||
const response = await paymentAPI.getConfig()
|
||||
config.value = response.data
|
||||
configLoaded.value = true
|
||||
return config.value
|
||||
} catch (error: unknown) {
|
||||
console.error('[payment] Failed to fetch config:', error)
|
||||
return null
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch available subscription plans */
|
||||
async function fetchPlans(): Promise<SubscriptionPlan[]> {
|
||||
try {
|
||||
const response = await paymentAPI.getPlans()
|
||||
// Backend returns features as newline-separated string; parse to array
|
||||
plans.value = (response.data || []).map((p: Omit<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
|
||||
...p,
|
||||
features: typeof p.features === 'string'
|
||||
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
|
||||
: (p.features || []),
|
||||
}))
|
||||
return plans.value
|
||||
} catch (error: unknown) {
|
||||
console.error('[payment] Failed to fetch plans:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new order and set it as current */
|
||||
async function createOrder(params: CreateOrderRequest) {
|
||||
const response = await paymentAPI.createOrder(params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Poll order status by ID */
|
||||
async function pollOrderStatus(orderId: number): Promise<PaymentOrder | null> {
|
||||
try {
|
||||
const response = await paymentAPI.getOrder(orderId)
|
||||
const order = response.data
|
||||
if (currentOrder.value?.id === orderId) {
|
||||
currentOrder.value = order
|
||||
}
|
||||
return order
|
||||
} catch (error: unknown) {
|
||||
console.error('[payment] Failed to poll order status:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear current order state */
|
||||
function clearCurrentOrder() {
|
||||
currentOrder.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
currentOrder,
|
||||
plans,
|
||||
configLoading,
|
||||
configLoaded,
|
||||
fetchConfig,
|
||||
fetchPlans,
|
||||
createOrder,
|
||||
pollOrderStatus,
|
||||
clearCurrentOrder
|
||||
}
|
||||
})
|
||||
@@ -16,20 +16,22 @@
|
||||
@apply min-h-screen;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
/* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */
|
||||
@supports (-moz-appearance:none) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*:hover,
|
||||
*:focus-within {
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
}
|
||||
*:hover,
|
||||
*:focus-within {
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
}
|
||||
|
||||
.dark *:hover,
|
||||
.dark *:focus-within {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
|
||||
.dark *:hover,
|
||||
.dark *:focus-within {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -58,36 +60,7 @@
|
||||
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
|
||||
}
|
||||
|
||||
/*
|
||||
* 表格滚动容器:始终显示滚动条,不跟随全局悬停策略。
|
||||
*
|
||||
* 浏览器兼容性说明:
|
||||
* - Chrome 121+ 原生支持 scrollbar-color / scrollbar-width。
|
||||
* 一旦元素匹配了这两个标准属性,::-webkit-scrollbar-* 被完全忽略。
|
||||
* 全局 * { scrollbar-width: thin } 使所有元素都走标准属性,
|
||||
* 因此 Chrome 121+ 只看 scrollbar-color。
|
||||
* - Chrome < 121 不认识标准属性,只看 ::-webkit-scrollbar-*,
|
||||
* 所以保留 ::-webkit-scrollbar-thumb 作为回退。
|
||||
* - Firefox 始终只看 scrollbar-color / scrollbar-width。
|
||||
*/
|
||||
.table-wrapper {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.7) transparent;
|
||||
}
|
||||
.dark .table-wrapper {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.7) transparent;
|
||||
}
|
||||
/* 旧版 Chrome (< 121) 兼容回退 */
|
||||
.table-wrapper::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-gray-400/70;
|
||||
}
|
||||
.dark .table-wrapper::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-gray-500/70;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -141,6 +114,27 @@
|
||||
@apply dark:shadow-amber-500/20;
|
||||
}
|
||||
|
||||
.btn-stripe {
|
||||
@apply bg-[#635bff] text-white shadow-md shadow-[#635bff]/25;
|
||||
@apply hover:bg-[#5851ea] hover:shadow-lg hover:shadow-[#635bff]/30;
|
||||
@apply dark:bg-[#7a73ff] dark:shadow-[#7a73ff]/20;
|
||||
@apply dark:hover:bg-[#635bff];
|
||||
}
|
||||
|
||||
.btn-alipay {
|
||||
@apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25;
|
||||
@apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30;
|
||||
@apply active:bg-[#008cbe];
|
||||
@apply dark:shadow-[#00AEEF]/20;
|
||||
}
|
||||
|
||||
.btn-wxpay {
|
||||
@apply bg-[#2BB741] text-white shadow-md shadow-[#2BB741]/25;
|
||||
@apply hover:bg-[#24a038] hover:shadow-lg hover:shadow-[#2BB741]/30;
|
||||
@apply active:bg-[#1d8a2f];
|
||||
@apply dark:shadow-[#2BB741]/20;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply rounded-lg px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
@@ -104,14 +104,14 @@ export interface PublicSettings {
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
payment_enabled: boolean
|
||||
table_default_page_size: number
|
||||
table_page_size_options: number[]
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
linuxdo_oauth_enabled: boolean
|
||||
oidc_oauth_enabled: boolean
|
||||
oidc_oauth_provider_name: string
|
||||
sora_client_enabled: boolean
|
||||
backend_mode_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
@@ -1363,6 +1363,8 @@ export interface UsageQueryParams {
|
||||
billing_type?: number | null
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// ==================== Account Usage Statistics ====================
|
||||
@@ -1629,3 +1631,6 @@ export interface UpdateScheduledTestPlanRequest {
|
||||
max_results?: number
|
||||
auto_recover?: boolean
|
||||
}
|
||||
|
||||
// Payment types
|
||||
export type { SubscriptionPlan, PaymentOrder, CheckoutInfoResponse } from './payment'
|
||||
|
||||
173
frontend/src/types/payment.ts
Normal file
173
frontend/src/types/payment.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Payment System Type Definitions
|
||||
*/
|
||||
|
||||
// ==================== Enums / Union Types ====================
|
||||
|
||||
export type OrderStatus =
|
||||
| 'PENDING'
|
||||
| 'PAID'
|
||||
| 'RECHARGING'
|
||||
| 'COMPLETED'
|
||||
| 'EXPIRED'
|
||||
| 'CANCELLED'
|
||||
| 'FAILED'
|
||||
| 'REFUND_REQUESTED'
|
||||
| 'REFUNDING'
|
||||
| 'PARTIALLY_REFUNDED'
|
||||
| 'REFUNDED'
|
||||
| 'REFUND_FAILED'
|
||||
|
||||
export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay'
|
||||
|
||||
export type OrderType = 'balance' | 'subscription'
|
||||
|
||||
// ==================== Configuration ====================
|
||||
|
||||
export interface PaymentConfig {
|
||||
payment_enabled: boolean
|
||||
min_amount: number
|
||||
max_amount: number
|
||||
daily_limit: number
|
||||
max_pending_orders: number
|
||||
order_timeout_minutes: number
|
||||
balance_disabled: boolean
|
||||
enabled_payment_types: PaymentType[]
|
||||
help_image_url: string
|
||||
help_text: string
|
||||
stripe_publishable_key: string
|
||||
}
|
||||
|
||||
export interface MethodLimit {
|
||||
daily_limit: number
|
||||
daily_used: number
|
||||
daily_remaining: number
|
||||
single_min: number
|
||||
single_max: number
|
||||
fee_rate: number
|
||||
available: boolean
|
||||
}
|
||||
|
||||
/** Response from /payment/limits API */
|
||||
export interface MethodLimitsResponse {
|
||||
methods: Record<string, MethodLimit>
|
||||
global_min: number // widest min across all methods; 0 = no minimum
|
||||
global_max: number // widest max across all methods; 0 = no maximum
|
||||
}
|
||||
|
||||
/** Response from /payment/checkout-info API — single call for the payment page */
|
||||
export interface CheckoutInfoResponse {
|
||||
methods: Record<string, MethodLimit>
|
||||
global_min: number
|
||||
global_max: number
|
||||
plans: SubscriptionPlan[]
|
||||
balance_disabled: boolean
|
||||
help_text: string
|
||||
help_image_url: string
|
||||
stripe_publishable_key: string
|
||||
}
|
||||
|
||||
// ==================== Orders ====================
|
||||
|
||||
export interface PaymentOrder {
|
||||
id: number
|
||||
user_id: number
|
||||
amount: number
|
||||
pay_amount: number
|
||||
fee_rate: number
|
||||
payment_type: string
|
||||
out_trade_no: string
|
||||
status: OrderStatus
|
||||
order_type: OrderType
|
||||
created_at: string
|
||||
expires_at: string
|
||||
paid_at?: string
|
||||
completed_at?: string
|
||||
refund_amount: number
|
||||
refund_reason?: string
|
||||
refund_requested_at?: string
|
||||
refund_requested_by?: number
|
||||
refund_request_reason?: string
|
||||
plan_id?: number
|
||||
}
|
||||
|
||||
// ==================== Plans & Channels ====================
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
id: number
|
||||
group_id: number
|
||||
group_platform?: string
|
||||
group_name?: string
|
||||
rate_multiplier?: number
|
||||
daily_limit_usd?: number | null
|
||||
weekly_limit_usd?: number | null
|
||||
monthly_limit_usd?: number | null
|
||||
supported_model_scopes?: string[]
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
original_price?: number
|
||||
validity_days: number
|
||||
validity_unit: string
|
||||
/** Stored as JSON string in backend; API layer should parse before use */
|
||||
features: string[]
|
||||
for_sale: boolean
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface PaymentChannel {
|
||||
id: number
|
||||
group_id?: number
|
||||
name: string
|
||||
platform: string
|
||||
rate_multiplier: number
|
||||
description: string
|
||||
models: string[]
|
||||
features: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ==================== Providers ====================
|
||||
|
||||
export interface ProviderInstance {
|
||||
id: number
|
||||
provider_key: string
|
||||
name: string
|
||||
config: Record<string, string>
|
||||
supported_types: string[]
|
||||
enabled: boolean
|
||||
payment_mode: string
|
||||
refund_enabled: boolean
|
||||
limits: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
// ==================== Request / Response ====================
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
amount: number
|
||||
payment_type: string
|
||||
order_type: string
|
||||
plan_id?: number
|
||||
}
|
||||
|
||||
export interface CreateOrderResult {
|
||||
order_id: number
|
||||
pay_url?: string
|
||||
qr_code?: string
|
||||
client_secret?: string
|
||||
pay_amount: number
|
||||
expires_at: string
|
||||
payment_mode?: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
today_amount: number
|
||||
total_amount: number
|
||||
today_count: number
|
||||
total_count: number
|
||||
avg_amount: number
|
||||
daily_series: { date: string; amount: number; count: number }[]
|
||||
payment_methods: { type: string; amount: number; count: number }[]
|
||||
top_users: { user_id: number; email: string; amount: number }[]
|
||||
}
|
||||
74
frontend/src/utils/__tests__/tablePreferences.spec.ts
Normal file
74
frontend/src/utils/__tests__/tablePreferences.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE,
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
getConfiguredTableDefaultPageSize,
|
||||
getConfiguredTablePageSizeOptions,
|
||||
normalizeTablePageSize
|
||||
} from '@/utils/tablePreferences'
|
||||
|
||||
describe('tablePreferences', () => {
|
||||
afterEach(() => {
|
||||
delete window.__APP_CONFIG__
|
||||
})
|
||||
|
||||
it('returns built-in defaults when app config is missing', () => {
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(DEFAULT_TABLE_PAGE_SIZE)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual(DEFAULT_TABLE_PAGE_SIZE_OPTIONS)
|
||||
})
|
||||
|
||||
it('uses configured defaults when app config is valid', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 50,
|
||||
table_page_size_options: [20, 50, 100]
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(50)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([20, 50, 100])
|
||||
})
|
||||
|
||||
it('allows default page size outside selectable options', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 1000,
|
||||
table_page_size_options: [20, 50, 100]
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(1000)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([20, 50, 100])
|
||||
expect(normalizeTablePageSize(1000)).toBe(100)
|
||||
expect(normalizeTablePageSize(35)).toBe(50)
|
||||
})
|
||||
|
||||
it('normalizes invalid options without rewriting the configured default itself', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 35,
|
||||
table_page_size_options: [1001, 50, 10, 10, 2, 0]
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(35)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([10, 50])
|
||||
expect(normalizeTablePageSize(undefined)).toBe(50)
|
||||
})
|
||||
|
||||
it('normalizes page size against configured options by rounding up', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 20,
|
||||
table_page_size_options: [20, 50, 1000]
|
||||
} as any
|
||||
|
||||
expect(normalizeTablePageSize(20)).toBe(20)
|
||||
expect(normalizeTablePageSize(30)).toBe(50)
|
||||
expect(normalizeTablePageSize(100)).toBe(1000)
|
||||
expect(normalizeTablePageSize(1500)).toBe(1000)
|
||||
expect(normalizeTablePageSize(undefined)).toBe(20)
|
||||
})
|
||||
|
||||
it('keeps built-in selectable defaults at 10, 20, 50, 100', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 1000
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([10, 20, 50, 100])
|
||||
})
|
||||
})
|
||||
71
frontend/src/utils/apiError.ts
Normal file
71
frontend/src/utils/apiError.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Centralized API error message extraction
|
||||
*
|
||||
* The API client interceptor rejects with a plain object: { status, code, message, error }
|
||||
* This utility extracts the user-facing message from any error shape.
|
||||
*/
|
||||
|
||||
interface ApiErrorLike {
|
||||
status?: number
|
||||
code?: number | string
|
||||
message?: string
|
||||
error?: string
|
||||
reason?: string
|
||||
metadata?: Record<string, unknown>
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string
|
||||
message?: string
|
||||
code?: number | string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the error code from an API error object.
|
||||
*/
|
||||
export function extractApiErrorCode(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== 'object') return undefined
|
||||
const e = err as ApiErrorLike
|
||||
const code = e.code ?? e.reason ?? e.response?.data?.code
|
||||
return code != null ? String(code) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a displayable error message from an API error.
|
||||
*
|
||||
* @param err - The caught error (unknown type)
|
||||
* @param fallback - Fallback message if none can be extracted (use t('common.error') or similar)
|
||||
* @param i18nMap - Optional map of error codes to i18n translated strings
|
||||
*/
|
||||
export function extractApiErrorMessage(
|
||||
err: unknown,
|
||||
fallback = 'Unknown error',
|
||||
i18nMap?: Record<string, string>,
|
||||
): string {
|
||||
if (!err) return fallback
|
||||
|
||||
// Try i18n mapping by error code first
|
||||
if (i18nMap) {
|
||||
const code = extractApiErrorCode(err)
|
||||
if (code && i18nMap[code]) return i18nMap[code]
|
||||
}
|
||||
|
||||
// Plain object from API client interceptor (most common case)
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const e = err as ApiErrorLike
|
||||
// Interceptor shape: { message, error }
|
||||
if (e.message) return e.message
|
||||
if (e.error) return e.error
|
||||
// Legacy axios shape: { response.data.detail }
|
||||
if (e.response?.data?.detail) return e.response.data.detail
|
||||
if (e.response?.data?.message) return e.response.data.message
|
||||
}
|
||||
|
||||
// Standard Error
|
||||
if (err instanceof Error) return err.message
|
||||
|
||||
// Last resort
|
||||
const str = String(err)
|
||||
return str === '[object Object]' ? fallback : str
|
||||
}
|
||||
11
frontend/src/utils/device.ts
Normal file
11
frontend/src/utils/device.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Detect whether the current device is mobile.
|
||||
* Uses navigator.userAgentData (modern API) with UA regex fallback.
|
||||
*/
|
||||
export function isMobileDevice(): boolean {
|
||||
const nav = navigator as unknown as Record<string, unknown>
|
||||
if (nav.userAgentData && typeof (nav.userAgentData as Record<string, unknown>).mobile === 'boolean') {
|
||||
return (nav.userAgentData as Record<string, unknown>).mobile as boolean
|
||||
}
|
||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
||||
}
|
||||
165
frontend/src/utils/platformColors.ts
Normal file
165
frontend/src/utils/platformColors.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Centralized platform color definitions.
|
||||
*
|
||||
* All components that need platform-specific styling should import from here
|
||||
* instead of defining their own color mappings.
|
||||
*/
|
||||
|
||||
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
|
||||
|
||||
// ── Badge (bg + text + border, for inline badges with border) ───────
|
||||
const BADGE: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-500/10 text-orange-600 border-orange-500/30 dark:text-orange-400',
|
||||
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
|
||||
}
|
||||
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
|
||||
|
||||
// ── Light badge (softer bg, no border) ──────────────────────────────
|
||||
const BADGE_LIGHT: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-500/10 text-orange-600 dark:bg-orange-500/10 dark:text-orange-300',
|
||||
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
|
||||
}
|
||||
|
||||
// ── Border ──────────────────────────────────────────────────────────
|
||||
const BORDER: Record<Platform, string> = {
|
||||
anthropic: 'border-orange-500/20 dark:border-orange-500/20',
|
||||
openai: 'border-green-500/20 dark:border-green-500/20',
|
||||
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
|
||||
gemini: 'border-blue-500/20 dark:border-blue-500/20',
|
||||
}
|
||||
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
|
||||
|
||||
// ── Accent bar (gradient) ───────────────────────────────────────────
|
||||
const ACCENT_BAR: Record<Platform, string> = {
|
||||
anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500',
|
||||
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
|
||||
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
|
||||
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
|
||||
}
|
||||
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
|
||||
|
||||
// ── Text (price, icon) ─────────────────────────────────────────────
|
||||
const TEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-600 dark:text-orange-400',
|
||||
openai: 'text-emerald-600 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-600 dark:text-purple-400',
|
||||
gemini: 'text-blue-600 dark:text-blue-400',
|
||||
}
|
||||
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
|
||||
|
||||
// ── Icon (check mark etc.) ──────────────────────────────────────────
|
||||
const ICON: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-500 dark:text-orange-400',
|
||||
openai: 'text-emerald-500 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-500 dark:text-purple-400',
|
||||
gemini: 'text-blue-500 dark:text-blue-400',
|
||||
}
|
||||
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
|
||||
|
||||
// ── Button (solid bg) ───────────────────────────────────────────────
|
||||
const BUTTON: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-500 text-white hover:bg-orange-600 active:bg-orange-700 dark:bg-orange-500/80 dark:hover:bg-orange-500',
|
||||
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
|
||||
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
|
||||
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
|
||||
}
|
||||
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
|
||||
|
||||
// ── Discount badge ──────────────────────────────────────────────────
|
||||
const DISCOUNT: Record<Platform, string> = {
|
||||
anthropic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
}
|
||||
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
|
||||
// ── Header gradient (subscription confirm) ─────────────────────────
|
||||
const GRADIENT: Record<Platform, string> = {
|
||||
anthropic: 'from-orange-500 to-orange-600',
|
||||
openai: 'from-emerald-500 to-emerald-600',
|
||||
antigravity: 'from-purple-500 to-purple-600',
|
||||
gemini: 'from-blue-500 to-blue-600',
|
||||
}
|
||||
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
|
||||
|
||||
// ── Header text (light text on gradient bg) ────────────────────────
|
||||
const GRADIENT_TEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-100',
|
||||
openai: 'text-emerald-100',
|
||||
antigravity: 'text-purple-100',
|
||||
gemini: 'text-blue-100',
|
||||
}
|
||||
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
|
||||
|
||||
const GRADIENT_SUBTEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-200',
|
||||
openai: 'text-emerald-200',
|
||||
antigravity: 'text-purple-200',
|
||||
gemini: 'text-blue-200',
|
||||
}
|
||||
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
function isPlatform(p: string): p is Platform {
|
||||
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
|
||||
}
|
||||
|
||||
export function platformBadgeClass(p: string): string {
|
||||
return isPlatform(p) ? BADGE[p] : BADGE_DEFAULT
|
||||
}
|
||||
|
||||
export function platformBadgeLightClass(p: string): string {
|
||||
return isPlatform(p) ? BADGE_LIGHT[p] : BADGE_DEFAULT
|
||||
}
|
||||
|
||||
export function platformBorderClass(p: string): string {
|
||||
return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT
|
||||
}
|
||||
|
||||
export function platformAccentBarClass(p: string): string {
|
||||
return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT
|
||||
}
|
||||
|
||||
export function platformTextClass(p: string): string {
|
||||
return isPlatform(p) ? TEXT[p] : TEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformIconClass(p: string): string {
|
||||
return isPlatform(p) ? ICON[p] : ICON_DEFAULT
|
||||
}
|
||||
|
||||
export function platformButtonClass(p: string): string {
|
||||
return isPlatform(p) ? BUTTON[p] : BUTTON_DEFAULT
|
||||
}
|
||||
|
||||
export function platformDiscountClass(p: string): string {
|
||||
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT[p] : GRADIENT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientTextClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT_TEXT[p] : GRADIENT_TEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientSubtextClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT_SUBTEXT[p] : GRADIENT_SUBTEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformLabel(p: string): string {
|
||||
switch (p) {
|
||||
case 'anthropic': return 'Anthropic'
|
||||
case 'openai': return 'OpenAI'
|
||||
case 'antigravity': return 'Antigravity'
|
||||
case 'gemini': return 'Gemini'
|
||||
default: return p || 'API'
|
||||
}
|
||||
}
|
||||
73
frontend/src/utils/tablePreferences.ts
Normal file
73
frontend/src/utils/tablePreferences.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const MIN_TABLE_PAGE_SIZE = 5
|
||||
const MAX_TABLE_PAGE_SIZE = 1000
|
||||
|
||||
export const DEFAULT_TABLE_PAGE_SIZE = 20
|
||||
export const DEFAULT_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
||||
|
||||
const sanitizePageSize = (value: unknown): number | null => {
|
||||
const size = Number(value)
|
||||
if (!Number.isInteger(size)) return null
|
||||
if (size < MIN_TABLE_PAGE_SIZE || size > MAX_TABLE_PAGE_SIZE) return null
|
||||
return size
|
||||
}
|
||||
|
||||
const parsePageSizeForSelection = (value: unknown): number | null => {
|
||||
const size = Number(value)
|
||||
if (!Number.isInteger(size)) return null
|
||||
if (size < MIN_TABLE_PAGE_SIZE) return null
|
||||
return size
|
||||
}
|
||||
|
||||
const getInjectedAppConfig = () => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.__APP_CONFIG__ ?? null
|
||||
}
|
||||
|
||||
const getSanitizedConfiguredOptions = (): number[] => {
|
||||
const configured = getInjectedAppConfig()?.table_page_size_options
|
||||
if (!Array.isArray(configured)) return []
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
configured
|
||||
.map((value) => sanitizePageSize(value))
|
||||
.filter((value): value is number => value !== null)
|
||||
)
|
||||
).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
const normalizePageSizeToOptions = (value: number, options: number[]): number => {
|
||||
for (const option of options) {
|
||||
if (option >= value) {
|
||||
return option
|
||||
}
|
||||
}
|
||||
return options[options.length - 1]
|
||||
}
|
||||
|
||||
export const getConfiguredTableDefaultPageSize = (): number => {
|
||||
const configured = sanitizePageSize(getInjectedAppConfig()?.table_default_page_size)
|
||||
if (configured === null) {
|
||||
return DEFAULT_TABLE_PAGE_SIZE
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
export const getConfiguredTablePageSizeOptions = (): number[] => {
|
||||
const unique = getSanitizedConfiguredOptions()
|
||||
if (unique.length === 0) {
|
||||
return [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
|
||||
}
|
||||
|
||||
return unique.length > 0 ? unique : [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
|
||||
}
|
||||
|
||||
export const normalizeTablePageSize = (value: unknown): number => {
|
||||
const normalized = parsePageSizeForSelection(value)
|
||||
const defaultSize = getConfiguredTableDefaultPageSize()
|
||||
const options = getConfiguredTablePageSizeOptions()
|
||||
if (normalized !== null) {
|
||||
return normalizePageSizeToOptions(normalized, options)
|
||||
}
|
||||
return normalizePageSizeToOptions(defaultSize, options)
|
||||
}
|
||||
@@ -148,6 +148,8 @@
|
||||
:data="accounts"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:server-side-sort="true"
|
||||
@sort="handleSort"
|
||||
default-sort-key="name"
|
||||
default-sort-order="asc"
|
||||
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||
@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||
|
||||
// Sorting settings
|
||||
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
|
||||
type AccountSortOrder = 'asc' | 'desc'
|
||||
type AccountSortState = {
|
||||
sort_by: string
|
||||
sort_order: AccountSortOrder
|
||||
}
|
||||
const ACCOUNT_SORTABLE_KEYS = new Set([
|
||||
'name',
|
||||
'status',
|
||||
'schedulable',
|
||||
'priority',
|
||||
'rate_multiplier',
|
||||
'last_used_at',
|
||||
'expires_at'
|
||||
])
|
||||
const loadInitialAccountSortState = (): AccountSortState => {
|
||||
const fallback: AccountSortState = { sort_by: 'name', sort_order: 'asc' }
|
||||
try {
|
||||
const raw = localStorage.getItem(ACCOUNT_SORT_STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const parsed = JSON.parse(raw) as { key?: string; order?: string }
|
||||
const key = typeof parsed.key === 'string' ? parsed.key : ''
|
||||
if (!ACCOUNT_SORTABLE_KEYS.has(key)) return fallback
|
||||
return {
|
||||
sort_by: key,
|
||||
sort_order: parsed.order === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
const sortState = reactive<AccountSortState>(loadInitialAccountSortState())
|
||||
|
||||
// Auto refresh settings
|
||||
const showAutoRefreshDropdown = ref(false)
|
||||
@@ -594,7 +627,16 @@ const {
|
||||
handlePageSizeChange: baseHandlePageSizeChange
|
||||
} = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', privacy_mode: '', group: '', search: '' }
|
||||
initialParams: {
|
||||
platform: '',
|
||||
type: '',
|
||||
status: '',
|
||||
privacy_mode: '',
|
||||
group: '',
|
||||
search: '',
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
|
||||
baseHandlePageSizeChange(size)
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: AccountSortOrder) => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
const requestParams = params as any
|
||||
requestParams.sort_by = key
|
||||
requestParams.sort_order = order
|
||||
pagination.page = 1
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
pendingTodayStatsRefresh.value = true
|
||||
load()
|
||||
}
|
||||
|
||||
watch(loading, (isLoading, wasLoading) => {
|
||||
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
|
||||
pendingTodayStatsRefresh.value = false
|
||||
@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
|
||||
privacy_mode?: string
|
||||
group?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: AccountSortOrder
|
||||
|
||||
},
|
||||
{ etag: autoRefreshETag.value }
|
||||
@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
||||
}
|
||||
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
|
||||
const handleDataImported = () => { showImportData.value = false; reload() }
|
||||
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
|
||||
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
|
||||
const buildAccountQueryFilters = () => ({
|
||||
platform: params.platform || '',
|
||||
type: params.type || '',
|
||||
status: params.status || '',
|
||||
group: params.group || '',
|
||||
privacy_mode: params.privacy_mode || '',
|
||||
search: params.search || '',
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
const accountMatchesCurrentFilters = (account: Account) => {
|
||||
if (params.platform && account.platform !== params.platform) return false
|
||||
if (params.type && account.type !== params.type) return false
|
||||
if (params.status) {
|
||||
if (params.status === 'rate_limited') {
|
||||
if (!account.rate_limit_reset_at) return false
|
||||
const resetAt = new Date(account.rate_limit_reset_at).getTime()
|
||||
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false
|
||||
} else if (account.status !== params.status) {
|
||||
const filters = buildAccountQueryFilters()
|
||||
if (filters.platform && account.platform !== filters.platform) return false
|
||||
if (filters.type && account.type !== filters.type) return false
|
||||
if (filters.status) {
|
||||
const now = Date.now()
|
||||
const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
|
||||
const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
|
||||
const tempUnschedUntil = account.temp_unschedulable_until ? new Date(account.temp_unschedulable_until).getTime() : Number.NaN
|
||||
const isTempUnschedulable = Number.isFinite(tempUnschedUntil) && tempUnschedUntil > now
|
||||
|
||||
if (filters.status === 'active') {
|
||||
if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false
|
||||
} else if (filters.status === 'rate_limited') {
|
||||
if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false
|
||||
} else if (filters.status === 'temp_unschedulable') {
|
||||
if (account.status !== 'active' || !isTempUnschedulable) return false
|
||||
} else if (filters.status === 'unschedulable') {
|
||||
if (account.status !== 'active' || account.schedulable || isRateLimited || isTempUnschedulable) return false
|
||||
} else if (account.status !== filters.status) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const search = String(params.search || '').trim().toLowerCase()
|
||||
if (filters.group) {
|
||||
const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? []
|
||||
if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) {
|
||||
if (groupIds.length > 0) return false
|
||||
} else if (!groupIds.includes(Number(filters.group))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
|
||||
if (filters.privacy_mode) {
|
||||
if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) {
|
||||
if (privacyMode.trim() !== '') return false
|
||||
} else if (privacyMode !== filters.privacy_mode) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const search = String(filters.search || '').trim().toLowerCase()
|
||||
if (search && !account.name.toLowerCase().includes(search)) return false
|
||||
return true
|
||||
}
|
||||
@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
|
||||
? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
|
||||
: {
|
||||
includeProxies: includeProxyOnExport.value,
|
||||
filters: {
|
||||
platform: params.platform,
|
||||
type: params.type,
|
||||
status: params.status,
|
||||
search: params.search
|
||||
}
|
||||
filters: buildAccountQueryFilters()
|
||||
}
|
||||
)
|
||||
const timestamp = formatExportTimestamp()
|
||||
|
||||
@@ -39,7 +39,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="announcements" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="announcements"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-title="{ value, row }">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -68,7 +76,7 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-notifyMode="{ row }">
|
||||
<template #cell-notify_mode="{ row }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
@@ -100,7 +108,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ value }">
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
@@ -236,7 +244,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
@@ -276,6 +284,11 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.announcements.allStatus') },
|
||||
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
|
||||
@@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [
|
||||
])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'title', label: t('admin.announcements.columns.title') },
|
||||
{ key: 'status', label: t('admin.announcements.columns.status') },
|
||||
{ key: 'notifyMode', label: t('admin.announcements.columns.notifyMode') },
|
||||
{ key: 'title', label: t('admin.announcements.columns.title'), sortable: true },
|
||||
{ key: 'status', label: t('admin.announcements.columns.status'), sortable: true },
|
||||
{ key: 'notify_mode', label: t('admin.announcements.columns.notifyMode'), sortable: true },
|
||||
{ key: 'targeting', label: t('admin.announcements.columns.targeting') },
|
||||
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
|
||||
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
|
||||
{ key: 'created_at', label: t('admin.announcements.columns.createdAt'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.announcements.columns.actions') }
|
||||
])
|
||||
|
||||
@@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => {
|
||||
let currentController: AbortController | null = null
|
||||
|
||||
async function loadAnnouncements() {
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
currentController?.abort()
|
||||
const requestController = new AbortController()
|
||||
currentController = requestController
|
||||
const { signal } = requestController
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
})
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}, { signal })
|
||||
|
||||
if (signal.aborted || currentController !== requestController) return
|
||||
|
||||
announcements.value = res.items
|
||||
pagination.total = res.total
|
||||
@@ -337,11 +356,21 @@ async function loadAnnouncements() {
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
if (
|
||||
signal.aborted ||
|
||||
currentController !== requestController ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error('Error loading announcements:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (currentController === requestController) {
|
||||
loading.value = false
|
||||
currentController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +390,13 @@ function handleStatusChange() {
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
@@ -562,4 +598,9 @@ onMounted(async () => {
|
||||
await loadSubscriptionGroups()
|
||||
await loadAnnouncements()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
currentController?.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="channels" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="channels"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -486,6 +494,10 @@ const pagination = reactive({
|
||||
page_size: getPersistedPageSize(),
|
||||
total: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
// Dialog state
|
||||
const showDialog = ref(false)
|
||||
@@ -766,7 +778,9 @@ async function loadChannels() {
|
||||
try {
|
||||
const response = await adminAPI.channels.list(pagination.page, pagination.page_size, {
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}, { signal: ctrl.signal })
|
||||
|
||||
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||||
@@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) {
|
||||
loadChannels()
|
||||
}
|
||||
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadChannels()
|
||||
}
|
||||
|
||||
// ── Dialog ──
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
|
||||
@@ -81,7 +81,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="groups" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="groups"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="sort_order"
|
||||
default-sort-order="asc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
value
|
||||
@@ -2924,6 +2932,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0,
|
||||
});
|
||||
const sortState = reactive({
|
||||
sort_by: "sort_order",
|
||||
sort_order: "asc" as "asc" | "desc",
|
||||
});
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
@@ -3290,6 +3302,8 @@ const loadGroups = async () => {
|
||||
? filters.is_exclusive === "true"
|
||||
: undefined,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -3392,6 +3406,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key;
|
||||
sortState.sort_order = order;
|
||||
pagination.page = 1;
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false;
|
||||
createModelRoutingRules.value.forEach((rule) => {
|
||||
|
||||
@@ -39,7 +39,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="codes"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<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>
|
||||
@@ -349,7 +357,6 @@
|
||||
: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() }"
|
||||
/>
|
||||
@@ -418,6 +425,10 @@ const pagination = reactive({
|
||||
page_size: getPersistedPageSize(),
|
||||
total: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
// Dialogs
|
||||
const showCreateDialog = ref(false)
|
||||
@@ -514,19 +525,29 @@ const loadCodes = async () => {
|
||||
pagination.page_size,
|
||||
{
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{ signal: currentController.signal }
|
||||
)
|
||||
if (currentController.signal.aborted) return
|
||||
if (currentController.signal.aborted || abortController !== currentController) return
|
||||
|
||||
codes.value = response.items
|
||||
pagination.total = response.total
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
if (
|
||||
currentController.signal.aborted ||
|
||||
abortController !== currentController ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.promo.failedToLoad'))
|
||||
console.error('Error loading promo codes:', error)
|
||||
} finally {
|
||||
if (abortController === currentController && !currentController.signal.aborted) {
|
||||
if (abortController === currentController) {
|
||||
loading.value = false
|
||||
abortController = null
|
||||
}
|
||||
@@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
const success = await clipboardCopy(text, t('admin.promo.copied'))
|
||||
if (success) {
|
||||
|
||||
@@ -89,7 +89,15 @@
|
||||
|
||||
<template #table>
|
||||
<div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="proxies"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="id"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #header-select>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -946,6 +954,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'id',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const createPasswordVisible = ref(false)
|
||||
@@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => {
|
||||
toggleVisible(target.checked)
|
||||
}
|
||||
|
||||
const buildProxyQueryFilters = () => ({
|
||||
protocol: filters.protocol || undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
|
||||
const loadProxies = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -1058,11 +1078,12 @@ const loadProxies = async () => {
|
||||
abortController = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, {
|
||||
protocol: filters.protocol || undefined,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
}, { signal: currentAbortController.signal })
|
||||
const response = await adminAPI.proxies.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
buildProxyQueryFilters(),
|
||||
{ signal: currentAbortController.signal }
|
||||
)
|
||||
if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
|
||||
return
|
||||
}
|
||||
@@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createMode.value = 'standard'
|
||||
@@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
|
||||
{
|
||||
protocol: filters.protocol || undefined,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}
|
||||
)
|
||||
result.push(...response.items)
|
||||
@@ -1689,11 +1719,7 @@ const handleExportData = async () => {
|
||||
selectedCount.value > 0
|
||||
? { ids: Array.from(selectedProxyIds.value) }
|
||||
: {
|
||||
filters: {
|
||||
protocol: filters.protocol || undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
filters: buildProxyQueryFilters()
|
||||
}
|
||||
)
|
||||
const timestamp = formatExportTimestamp()
|
||||
|
||||
@@ -47,7 +47,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="codes"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="id"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<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>
|
||||
@@ -537,6 +545,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'id',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
@@ -565,6 +577,14 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const buildRedeemQueryFilters = () => ({
|
||||
type: (filters.type || undefined) as RedeemCodeType | undefined,
|
||||
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
|
||||
const loadCodes = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -576,11 +596,7 @@ const loadCodes = async () => {
|
||||
const response = await adminAPI.redeem.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
},
|
||||
buildRedeemQueryFilters(),
|
||||
{
|
||||
signal: currentController.signal
|
||||
}
|
||||
@@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleGenerateCodes = async () => {
|
||||
// 订阅类型必须选择分组
|
||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||
@@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => {
|
||||
|
||||
const handleExportCodes = async () => {
|
||||
try {
|
||||
const blob = await adminAPI.redeem.exportCodes({
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any
|
||||
})
|
||||
const blob = await adminAPI.redeem.exportCodes(buildRedeemQueryFilters())
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
|
||||
@@ -1788,6 +1788,48 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Global Table Preferences -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.site.tablePreferencesTitle') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tablePreferencesDescription') }}
|
||||
</p>
|
||||
<div class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.tableDefaultPageSize') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.table_default_page_size"
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="1"
|
||||
class="input w-40"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tableDefaultPageSizeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.tablePageSizeOptions') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="tablePageSizeOptionsInput"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.site.tablePageSizeOptionsPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tablePageSizeOptionsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Endpoints -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -1947,70 +1989,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Subscription Page -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.purchase.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 p-6">
|
||||
<!-- Enable Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.purchase.enabled')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.purchase_subscription_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.purchase.url') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.purchase_subscription_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.purchase.urlPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.urlHint') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.settings.purchase.iframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Integration Docs -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<a
|
||||
href="https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:underline dark:text-blue-400"
|
||||
download="ADMIN_PAYMENT_INTEGRATION_API.md"
|
||||
>
|
||||
{{ t('admin.settings.purchase.integrationDoc') }}
|
||||
</a>
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.integrationDocHint') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Menu Items -->
|
||||
<div class="card">
|
||||
@@ -2136,6 +2114,124 @@
|
||||
</div><!-- /Tab: General -->
|
||||
|
||||
<!-- Tab: Email -->
|
||||
<!-- Tab: Payment -->
|
||||
<div v-show="activeTab === 'payment'" class="space-y-6">
|
||||
|
||||
<!-- Payment System Settings -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.payment.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.description') }}</p>
|
||||
</div>
|
||||
<div class="space-y-4 p-6">
|
||||
<!-- Enable toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.payment.enabled') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.payment_enabled" />
|
||||
</div>
|
||||
<template v-if="form.payment_enabled">
|
||||
<!-- Row 1: Product name -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.productNamePrefix') }}</label><input v-model="form.payment_product_name_prefix" type="text" class="input" placeholder="Sub2API" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.productNameSuffix') }}</label><input v-model="form.payment_product_name_suffix" type="text" class="input" placeholder="CNY" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.preview') }}</label><div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300">{{ (form.payment_product_name_prefix || 'Sub2API') + ' 100 ' + (form.payment_product_name_suffix || 'CNY') }}</div></div>
|
||||
</div>
|
||||
<!-- Row 2: Balance toggle + amounts -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input :value="form.payment_min_amount || ''" @input="form.payment_min_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input :value="form.payment_max_amount || ''" @input="form.payment_max_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input :value="form.payment_daily_limit || ''" @input="form.payment_daily_limit = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }} <span class="text-red-500">*</span></label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" required /><p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.orderTimeoutHint') }}</p></div>
|
||||
</div>
|
||||
<!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) -->
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="w-28"><label class="input-label">{{ t('admin.settings.payment.maxPendingOrders') }}</label><input v-model.number="form.payment_max_pending_orders" type="number" min="1" class="input" /></div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.loadBalanceStrategy') }}</label>
|
||||
<Select v-model="form.payment_load_balance_strategy" :options="loadBalanceOptions" class="w-40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.cancelRateLimit') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
form.payment_cancel_rate_limit_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
@click="form.payment_cancel_rate_limit_enabled = !form.payment_cancel_rate_limit_enabled"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
form.payment_cancel_rate_limit_enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]" />
|
||||
</button>
|
||||
<Select v-model="form.payment_cancel_rate_limit_window_mode" :options="cancelRateLimitModeOptions" class="w-24" :disabled="!form.payment_cancel_rate_limit_enabled" />
|
||||
<span :class="['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']">{{ t('admin.settings.payment.cancelRateLimitEvery') }}</span>
|
||||
<input v-model.number="form.payment_cancel_rate_limit_window" type="number" min="1" required class="input w-14 text-center" :disabled="!form.payment_cancel_rate_limit_enabled" />
|
||||
<Select v-model="form.payment_cancel_rate_limit_unit" :options="cancelRateLimitUnitOptions" class="w-28" :disabled="!form.payment_cancel_rate_limit_enabled" />
|
||||
<span :class="['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']">{{ t('admin.settings.payment.cancelRateLimitAllowMax') }}</span>
|
||||
<input v-model.number="form.payment_cancel_rate_limit_max" type="number" min="1" required class="input w-14 text-center" :disabled="!form.payment_cancel_rate_limit_enabled" />
|
||||
<span :class="['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']">{{ t('admin.settings.payment.cancelRateLimitTimes') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 4: Enabled payment types (provider badges like sub2apipay) -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label>
|
||||
<div class="mt-1.5 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="pt in allPaymentTypes"
|
||||
:key="pt.value"
|
||||
type="button"
|
||||
@click="togglePaymentType(pt.value)"
|
||||
:class="[
|
||||
'rounded-lg border px-3 py-1.5 text-sm font-medium transition-all',
|
||||
isPaymentTypeEnabled(pt.value)
|
||||
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
|
||||
]"
|
||||
>{{ pt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 5: Help image + text -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.helpImage') }}</label>
|
||||
<ImageUpload v-model="form.payment_help_image_url" :placeholder="t('admin.settings.payment.helpImagePlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.helpText') }}</label>
|
||||
<textarea v-model="form.payment_help_text" rows="3" class="input" :placeholder="t('admin.settings.payment.helpTextPlaceholder')"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider Management -->
|
||||
<PaymentProviderList
|
||||
v-if="form.payment_enabled"
|
||||
:providers="providers"
|
||||
:loading="providersLoading"
|
||||
:can-create="hasAnyPaymentTypeEnabled"
|
||||
:enabled-payment-types="form.payment_enabled_types"
|
||||
:all-payment-types="allPaymentTypes"
|
||||
:redirect-label="t('admin.settings.payment.easypayRedirect')"
|
||||
@refresh="loadProviders"
|
||||
@create="openCreateProvider"
|
||||
@edit="openEditProvider"
|
||||
@delete="confirmDeleteProvider"
|
||||
@toggle-field="handleToggleField"
|
||||
@toggle-type="handleToggleType"
|
||||
@reorder="handleReorderProviders"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'email'" class="space-y-6">
|
||||
<!-- Email disabled hint - show when email_verify_enabled is off -->
|
||||
<div v-if="!form.email_verify_enabled" class="card">
|
||||
@@ -2388,6 +2484,21 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Provider dialogs placed outside the settings form to prevent form submission bubbling -->
|
||||
<PaymentProviderDialog
|
||||
ref="providerDialogRef"
|
||||
:show="showProviderDialog"
|
||||
:saving="providerSaving"
|
||||
:editing="editingProvider"
|
||||
:all-key-options="providerKeyOptions"
|
||||
:enabled-key-options="enabledProviderKeyOptions"
|
||||
:all-payment-types="allPaymentTypes"
|
||||
:redirect-label="t('admin.settings.payment.easypayRedirect')"
|
||||
@close="showProviderDialog = false"
|
||||
@save="handleSaveProvider"
|
||||
/>
|
||||
<ConfirmDialog :show="showDeleteProviderDialog" :title="t('admin.settings.payment.deleteProvider')" :message="t('admin.settings.payment.deleteProviderConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeleteProvider" @cancel="showDeleteProviderDialog = false" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -2402,15 +2513,20 @@ import type {
|
||||
DefaultSubscriptionSetting
|
||||
} from '@/api/admin/settings'
|
||||
import type { AdminGroup } from '@/types'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import PaymentProviderList from '@/components/payment/PaymentProviderList.vue'
|
||||
import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import ImageUpload from '@/components/common/ImageUpload.vue'
|
||||
import BackupSettings from '@/views/admin/BackupView.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAdminSettingsStore } from '@/stores/adminSettings'
|
||||
import {
|
||||
@@ -2424,13 +2540,14 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
|
||||
type SettingsTab = 'general' | 'security' | 'users' | 'gateway' | 'email' | 'backup'
|
||||
type SettingsTab = 'general' | 'security' | 'users' | 'gateway' | 'payment' | 'email' | 'backup'
|
||||
const activeTab = ref<SettingsTab>('general')
|
||||
const settingsTabs = [
|
||||
{ key: 'general' as SettingsTab, icon: 'home' as const },
|
||||
{ key: 'security' as SettingsTab, icon: 'shield' as const },
|
||||
{ key: 'users' as SettingsTab, icon: 'user' as const },
|
||||
{ key: 'gateway' as SettingsTab, icon: 'server' as const },
|
||||
{ key: 'payment' as SettingsTab, icon: 'creditCard' as const },
|
||||
{ key: 'email' as SettingsTab, icon: 'mail' as const },
|
||||
{ key: 'backup' as SettingsTab, icon: 'database' as const },
|
||||
]
|
||||
@@ -2445,6 +2562,7 @@ const smtpPasswordManuallyEdited = ref(false)
|
||||
const testEmailAddress = ref('')
|
||||
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
||||
const registrationEmailSuffixWhitelistDraft = ref('')
|
||||
const tablePageSizeOptionsInput = ref('10, 20, 50, 100')
|
||||
|
||||
// Admin API Key 状态
|
||||
const adminApiKeyLoading = ref(true)
|
||||
@@ -2499,6 +2617,10 @@ const betaPolicyForm = reactive({
|
||||
}>
|
||||
})
|
||||
|
||||
const tablePageSizeMin = 5
|
||||
const tablePageSizeMax = 1000
|
||||
const tablePageSizeDefault = 20
|
||||
|
||||
interface DefaultSubscriptionGroupOption {
|
||||
value: number
|
||||
label: string
|
||||
@@ -2537,8 +2659,9 @@ const form = reactive<SettingsForm>({
|
||||
home_content: '',
|
||||
backend_mode_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
|
||||
table_default_page_size: tablePageSizeDefault,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
|
||||
frontend_url: '',
|
||||
@@ -2762,12 +2885,47 @@ function removeEndpoint(index: number) {
|
||||
form.custom_endpoints.splice(index, 1)
|
||||
}
|
||||
|
||||
function formatTablePageSizeOptions(options: number[]): string {
|
||||
return options.join(', ')
|
||||
}
|
||||
|
||||
function parseTablePageSizeOptionsInput(raw: string): number[] | null {
|
||||
const tokens = raw
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = tokens.map((token) => Number(token))
|
||||
if (parsed.some((value) => !Number.isInteger(value))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const deduped = Array.from(new Set(parsed)).sort((a, b) => a - b)
|
||||
if (
|
||||
deduped.some((value) => value < tablePageSizeMin || value > tablePageSizeMax)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return deduped
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const settings = await adminAPI.settings.getSettings()
|
||||
Object.assign(form, settings)
|
||||
settings.payment_load_balance_strategy = settings.payment_load_balance_strategy || 'round-robin'
|
||||
// Only assign non-null values from backend (null means unconfigured, keep defaults)
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
(form as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
form.backend_mode_enabled = settings.backend_mode_enabled
|
||||
form.default_subscriptions = Array.isArray(settings.default_subscriptions)
|
||||
? settings.default_subscriptions
|
||||
@@ -2780,17 +2938,18 @@ async function loadSettings() {
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
settings.registration_email_suffix_whitelist
|
||||
)
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(settings.table_page_size_options) ? settings.table_page_size_options : [10, 20, 50, 100]
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
form.turnstile_secret_key = ''
|
||||
form.linuxdo_connect_client_secret = ''
|
||||
form.oidc_connect_client_secret = ''
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
loadFailed.value = true
|
||||
appStore.showError(
|
||||
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -2802,8 +2961,7 @@ async function loadSubscriptionGroups() {
|
||||
subscriptionGroups.value = groups.filter(
|
||||
(group) => group.subscription_type === 'subscription' && group.status === 'active'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscription groups:', error)
|
||||
} catch (_error: unknown) {
|
||||
subscriptionGroups.value = []
|
||||
}
|
||||
}
|
||||
@@ -2826,6 +2984,37 @@ function removeDefaultSubscription(index: number) {
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const normalizedTableDefaultPageSize = Math.floor(Number(form.table_default_page_size))
|
||||
if (
|
||||
!Number.isInteger(normalizedTableDefaultPageSize) ||
|
||||
normalizedTableDefaultPageSize < tablePageSizeMin ||
|
||||
normalizedTableDefaultPageSize > tablePageSizeMax
|
||||
) {
|
||||
appStore.showError(
|
||||
t('admin.settings.site.tableDefaultPageSizeRangeError', {
|
||||
min: tablePageSizeMin,
|
||||
max: tablePageSizeMax
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedTablePageSizeOptions = parseTablePageSizeOptionsInput(
|
||||
tablePageSizeOptionsInput.value
|
||||
)
|
||||
if (!normalizedTablePageSizeOptions) {
|
||||
appStore.showError(
|
||||
t('admin.settings.site.tablePageSizeOptionsFormatError', {
|
||||
min: tablePageSizeMin,
|
||||
max: tablePageSizeMax
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
form.table_default_page_size = normalizedTableDefaultPageSize
|
||||
form.table_page_size_options = normalizedTablePageSizeOptions
|
||||
|
||||
const normalizedDefaultSubscriptions = form.default_subscriptions
|
||||
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
||||
.map((item: DefaultSubscriptionSetting) => ({
|
||||
@@ -2863,21 +3052,6 @@ async function saveSettings() {
|
||||
// Optional URL fields: auto-clear invalid values so they don't cause backend 400 errors
|
||||
if (!isValidHttpUrl(form.frontend_url)) form.frontend_url = ''
|
||||
if (!isValidHttpUrl(form.doc_url)) form.doc_url = ''
|
||||
// Purchase URL: required when enabled; auto-clear when disabled to avoid backend rejection
|
||||
if (form.purchase_subscription_enabled) {
|
||||
if (!form.purchase_subscription_url) {
|
||||
appStore.showError(t('admin.settings.purchase.url') + ': URL is required when purchase is enabled')
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
if (!isValidHttpUrl(form.purchase_subscription_url)) {
|
||||
appStore.showError(t('admin.settings.purchase.url') + ': must be an absolute http(s) URL (e.g. https://example.com)')
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
} else if (!isValidHttpUrl(form.purchase_subscription_url)) {
|
||||
form.purchase_subscription_url = ''
|
||||
}
|
||||
|
||||
const payload: UpdateSettingsRequest = {
|
||||
registration_enabled: form.registration_enabled,
|
||||
@@ -2901,8 +3075,8 @@ async function saveSettings() {
|
||||
home_content: form.home_content,
|
||||
backend_mode_enabled: form.backend_mode_enabled,
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||
purchase_subscription_url: form.purchase_subscription_url,
|
||||
table_default_page_size: form.table_default_page_size,
|
||||
table_page_size_options: form.table_page_size_options,
|
||||
custom_menu_items: form.custom_menu_items,
|
||||
custom_endpoints: form.custom_endpoints,
|
||||
frontend_url: form.frontend_url,
|
||||
@@ -2954,13 +3128,40 @@ async function saveSettings() {
|
||||
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling,
|
||||
enable_fingerprint_unification: form.enable_fingerprint_unification,
|
||||
enable_metadata_passthrough: form.enable_metadata_passthrough,
|
||||
enable_cch_signing: form.enable_cch_signing
|
||||
enable_cch_signing: form.enable_cch_signing,
|
||||
// Payment configuration
|
||||
payment_enabled: form.payment_enabled,
|
||||
payment_min_amount: Number(form.payment_min_amount) || 0,
|
||||
payment_max_amount: Number(form.payment_max_amount) || 0,
|
||||
payment_daily_limit: Number(form.payment_daily_limit) || 0,
|
||||
payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0,
|
||||
payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
|
||||
payment_balance_disabled: form.payment_balance_disabled,
|
||||
payment_enabled_types: form.payment_enabled_types,
|
||||
payment_load_balance_strategy: form.payment_load_balance_strategy,
|
||||
payment_product_name_prefix: form.payment_product_name_prefix,
|
||||
payment_product_name_suffix: form.payment_product_name_suffix,
|
||||
payment_help_image_url: form.payment_help_image_url,
|
||||
payment_help_text: form.payment_help_text,
|
||||
payment_cancel_rate_limit_enabled: form.payment_cancel_rate_limit_enabled,
|
||||
payment_cancel_rate_limit_max: Number(form.payment_cancel_rate_limit_max) || 10,
|
||||
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
|
||||
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
|
||||
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
|
||||
}
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
Object.assign(form, updated)
|
||||
for (const [key, value] of Object.entries(updated)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
(form as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist
|
||||
)
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(updated.table_page_size_options) ? updated.table_page_size_options : [10, 20, 50, 100]
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
@@ -2971,10 +3172,8 @@ async function saveSettings() {
|
||||
await appStore.fetchPublicSettings(true)
|
||||
await adminSettingsStore.fetch(true)
|
||||
appStore.showSuccess(t('admin.settings.settingsSaved'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -2993,10 +3192,8 @@ async function testSmtpConnection() {
|
||||
})
|
||||
// API returns { message: "..." } on success, errors are thrown as exceptions
|
||||
appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToTestSmtp')))
|
||||
} finally {
|
||||
testingSmtp.value = false
|
||||
}
|
||||
@@ -3023,10 +3220,8 @@ async function sendTestEmail() {
|
||||
})
|
||||
// API returns { message: "..." } on success, errors are thrown as exceptions
|
||||
appStore.showSuccess(result.message || t('admin.settings.testEmailSent'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSendTestEmail')))
|
||||
} finally {
|
||||
sendingTestEmail.value = false
|
||||
}
|
||||
@@ -3039,8 +3234,8 @@ async function loadAdminApiKey() {
|
||||
const status = await adminAPI.settings.getAdminApiKey()
|
||||
adminApiKeyExists.value = status.exists
|
||||
adminApiKeyMasked.value = status.masked_key
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load admin API key status:', error)
|
||||
} catch (_error: unknown) {
|
||||
// Silent fail - admin API key status is non-critical
|
||||
} finally {
|
||||
adminApiKeyLoading.value = false
|
||||
}
|
||||
@@ -3054,8 +3249,8 @@ async function createAdminApiKey() {
|
||||
adminApiKeyExists.value = true
|
||||
adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4)
|
||||
appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.message || t('common.error'))
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('common.error')))
|
||||
} finally {
|
||||
adminApiKeyOperating.value = false
|
||||
}
|
||||
@@ -3075,8 +3270,8 @@ async function deleteAdminApiKey() {
|
||||
adminApiKeyMasked.value = ''
|
||||
newAdminApiKey.value = ''
|
||||
appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.message || t('common.error'))
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('common.error')))
|
||||
} finally {
|
||||
adminApiKeyOperating.value = false
|
||||
}
|
||||
@@ -3099,8 +3294,8 @@ async function loadOverloadCooldownSettings() {
|
||||
try {
|
||||
const settings = await adminAPI.settings.getOverloadCooldownSettings()
|
||||
Object.assign(overloadCooldownForm, settings)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load overload cooldown settings:', error)
|
||||
} catch (_error: unknown) {
|
||||
// Silent fail - settings will use defaults
|
||||
} finally {
|
||||
overloadCooldownLoading.value = false
|
||||
}
|
||||
@@ -3115,10 +3310,8 @@ async function saveOverloadCooldownSettings() {
|
||||
})
|
||||
Object.assign(overloadCooldownForm, updated)
|
||||
appStore.showSuccess(t('admin.settings.overloadCooldown.saved'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.overloadCooldown.saveFailed')))
|
||||
} finally {
|
||||
overloadCooldownSaving.value = false
|
||||
}
|
||||
@@ -3130,8 +3323,8 @@ async function loadStreamTimeoutSettings() {
|
||||
try {
|
||||
const settings = await adminAPI.settings.getStreamTimeoutSettings()
|
||||
Object.assign(streamTimeoutForm, settings)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load stream timeout settings:', error)
|
||||
} catch (_error: unknown) {
|
||||
// Silent fail - settings will use defaults
|
||||
} finally {
|
||||
streamTimeoutLoading.value = false
|
||||
}
|
||||
@@ -3149,10 +3342,8 @@ async function saveStreamTimeoutSettings() {
|
||||
})
|
||||
Object.assign(streamTimeoutForm, updated)
|
||||
appStore.showSuccess(t('admin.settings.streamTimeout.saved'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.streamTimeout.saveFailed')))
|
||||
} finally {
|
||||
streamTimeoutSaving.value = false
|
||||
}
|
||||
@@ -3168,8 +3359,8 @@ async function loadRectifierSettings() {
|
||||
if (!Array.isArray(rectifierForm.apikey_signature_patterns)) {
|
||||
rectifierForm.apikey_signature_patterns = []
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load rectifier settings:', error)
|
||||
} catch (_error: unknown) {
|
||||
// Silent fail - settings will use defaults
|
||||
} finally {
|
||||
rectifierLoading.value = false
|
||||
}
|
||||
@@ -3192,10 +3383,8 @@ async function saveRectifierSettings() {
|
||||
rectifierForm.apikey_signature_patterns = []
|
||||
}
|
||||
appStore.showSuccess(t('admin.settings.rectifier.saved'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.rectifier.saveFailed') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.rectifier.saveFailed')))
|
||||
} finally {
|
||||
rectifierSaving.value = false
|
||||
}
|
||||
@@ -3267,8 +3456,8 @@ async function loadBetaPolicySettings() {
|
||||
try {
|
||||
const settings = await adminAPI.settings.getBetaPolicySettings()
|
||||
betaPolicyForm.rules = settings.rules
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load beta policy settings:', error)
|
||||
} catch (_error: unknown) {
|
||||
// Silent fail - settings will use defaults
|
||||
} finally {
|
||||
betaPolicyLoading.value = false
|
||||
}
|
||||
@@ -3296,15 +3485,182 @@ async function saveBetaPolicySettings() {
|
||||
})
|
||||
betaPolicyForm.rules = updated.rules
|
||||
appStore.showSuccess(t('admin.settings.betaPolicy.saved'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
t('admin.settings.betaPolicy.saveFailed') + ': ' + (error.message || t('common.unknownError'))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.settings.betaPolicy.saveFailed')))
|
||||
} finally {
|
||||
betaPolicySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Provider Management ====================
|
||||
|
||||
const allPaymentTypes = computed(() => [
|
||||
{ value: 'easypay', label: t('payment.methods.easypay') },
|
||||
{ value: 'alipay', label: t('payment.methods.alipay') },
|
||||
{ value: 'wxpay', label: t('payment.methods.wxpay') },
|
||||
{ value: 'stripe', label: t('payment.methods.stripe') },
|
||||
])
|
||||
|
||||
function isPaymentTypeEnabled(type: string): boolean {
|
||||
return form.payment_enabled_types.includes(type)
|
||||
}
|
||||
|
||||
const hasAnyPaymentTypeEnabled = computed(() => form.payment_enabled_types.length > 0)
|
||||
|
||||
function togglePaymentType(type: string) {
|
||||
if (form.payment_enabled_types.includes(type)) {
|
||||
form.payment_enabled_types = form.payment_enabled_types.filter(t => t !== type)
|
||||
// Disable all provider instances matching this type
|
||||
disableProvidersByType(type)
|
||||
} else {
|
||||
form.payment_enabled_types = [...form.payment_enabled_types, type]
|
||||
}
|
||||
}
|
||||
|
||||
async function disableProvidersByType(type: string) {
|
||||
const matching = providers.value.filter(p => p.provider_key === type && p.enabled)
|
||||
for (const p of matching) {
|
||||
try {
|
||||
await adminAPI.payment.updateProvider(p.id, { enabled: false })
|
||||
p.enabled = false
|
||||
} catch (err: unknown) {
|
||||
slog('disable provider failed', p.id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function slog(...args: unknown[]) { console.warn('[payment]', ...args) }
|
||||
|
||||
const providersLoading = ref(false)
|
||||
const providerSaving = ref(false)
|
||||
const providers = ref<ProviderInstance[]>([])
|
||||
const showProviderDialog = ref(false)
|
||||
const showDeleteProviderDialog = ref(false)
|
||||
const editingProvider = ref<ProviderInstance | null>(null)
|
||||
const deletingProviderId = ref<number | null>(null)
|
||||
const providerDialogRef = ref<InstanceType<typeof PaymentProviderDialog> | null>(null)
|
||||
|
||||
const providerKeyOptions = computed(() => [
|
||||
{ value: 'easypay', label: t('admin.settings.payment.providerEasypay') },
|
||||
{ value: 'alipay', label: t('admin.settings.payment.providerAlipay') },
|
||||
{ value: 'wxpay', label: t('admin.settings.payment.providerWxpay') },
|
||||
{ value: 'stripe', label: t('admin.settings.payment.providerStripe') },
|
||||
])
|
||||
|
||||
const enabledProviderKeyOptions = computed(() => {
|
||||
const enabled = form.payment_enabled_types
|
||||
return providerKeyOptions.value.filter(opt => enabled.includes(opt.value))
|
||||
})
|
||||
|
||||
const loadBalanceOptions = computed(() => [
|
||||
{ value: 'round-robin', label: t('admin.settings.payment.strategyRoundRobin') },
|
||||
{ value: 'least-amount', label: t('admin.settings.payment.strategyLeastAmount') },
|
||||
])
|
||||
|
||||
const cancelRateLimitUnitOptions = computed(() => [
|
||||
{ value: 'minute', label: t('admin.settings.payment.cancelRateLimitUnitMinute') },
|
||||
{ value: 'hour', label: t('admin.settings.payment.cancelRateLimitUnitHour') },
|
||||
{ value: 'day', label: t('admin.settings.payment.cancelRateLimitUnitDay') },
|
||||
])
|
||||
|
||||
const cancelRateLimitModeOptions = computed(() => [
|
||||
{ value: 'rolling', label: t('admin.settings.payment.cancelRateLimitWindowModeRolling') },
|
||||
{ value: 'fixed', label: t('admin.settings.payment.cancelRateLimitWindowModeFixed') },
|
||||
])
|
||||
|
||||
const paymentErrorMap = computed(() => ({
|
||||
PENDING_ORDERS: t('payment.errors.PENDING_ORDERS'),
|
||||
}))
|
||||
|
||||
async function loadProviders() {
|
||||
providersLoading.value = true
|
||||
try { const res = await adminAPI.payment.getProviders(); providers.value = res.data || [] }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { providersLoading.value = false }
|
||||
}
|
||||
|
||||
function openCreateProvider() {
|
||||
editingProvider.value = null
|
||||
providerDialogRef.value?.reset(enabledProviderKeyOptions.value[0]?.value || 'easypay')
|
||||
showProviderDialog.value = true
|
||||
}
|
||||
|
||||
function openEditProvider(provider: ProviderInstance) {
|
||||
editingProvider.value = provider
|
||||
providerDialogRef.value?.loadProvider(provider)
|
||||
showProviderDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSaveProvider(payload: Partial<ProviderInstance>) {
|
||||
providerSaving.value = true
|
||||
try {
|
||||
if (editingProvider.value) {
|
||||
await adminAPI.payment.updateProvider(editingProvider.value.id, payload)
|
||||
} else {
|
||||
await adminAPI.payment.createProvider(payload)
|
||||
}
|
||||
showProviderDialog.value = false
|
||||
// Reload full list (API returns decrypted/formatted data with correct sort order)
|
||||
await loadProviders()
|
||||
// Auto-save settings so provider changes take effect immediately
|
||||
await saveSettings()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value))
|
||||
} finally {
|
||||
providerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled') {
|
||||
const newValue = field === 'enabled' ? !provider.enabled : !provider.refund_enabled
|
||||
try {
|
||||
await adminAPI.payment.updateProvider(provider.id, { [field]: newValue })
|
||||
if (field === 'enabled') provider.enabled = newValue
|
||||
else provider.refund_enabled = newValue
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
|
||||
}
|
||||
|
||||
async function handleToggleType(provider: ProviderInstance, type: string) {
|
||||
const updated = provider.supported_types.includes(type)
|
||||
? provider.supported_types.filter(t => t !== type)
|
||||
: [...provider.supported_types, type]
|
||||
try {
|
||||
await adminAPI.payment.updateProvider(provider.id, { supported_types: updated } as any)
|
||||
provider.supported_types = updated
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
|
||||
}
|
||||
|
||||
function confirmDeleteProvider(provider: ProviderInstance) {
|
||||
deletingProviderId.value = provider.id
|
||||
showDeleteProviderDialog.value = true
|
||||
}
|
||||
|
||||
async function handleReorderProviders(updates: { id: number; sort_order: number }[]) {
|
||||
try {
|
||||
await Promise.all(
|
||||
updates.map(u => adminAPI.payment.updateProvider(u.id, { sort_order: u.sort_order } as Partial<ProviderInstance>))
|
||||
)
|
||||
// Update local state to match new order
|
||||
for (const u of updates) {
|
||||
const p = providers.value.find(p => p.id === u.id)
|
||||
if (p) p.sort_order = u.sort_order
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
loadProviders()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProvider() {
|
||||
if (!deletingProviderId.value) return
|
||||
try {
|
||||
await adminAPI.payment.deleteProvider(deletingProviderId.value)
|
||||
appStore.showSuccess(t('common.deleted'))
|
||||
showDeleteProviderDialog.value = false
|
||||
loadProviders()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadSubscriptionGroups()
|
||||
@@ -3313,6 +3669,7 @@ onMounted(() => {
|
||||
loadStreamTimeoutSettings()
|
||||
loadRectifierSettings()
|
||||
loadBetaPolicySettings()
|
||||
loadProviders()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
:data="subscriptions"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
|
||||
@@ -100,7 +100,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</UsageFilters>
|
||||
<UsageTable :data="usageLogs" :loading="loading" :columns="visibleColumns" @userClick="handleUserClick" />
|
||||
<UsageTable
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:columns="visibleColumns"
|
||||
:server-side-sort="true"
|
||||
:default-sort-key="'created_at'"
|
||||
:default-sort-order="'desc'"
|
||||
@sort="handleSort"
|
||||
@userClick="handleUserClick"
|
||||
/>
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates()
|
||||
const startDate = ref(defaultRange.start); const endDate = ref(defaultRange.end)
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||
const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 })
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => {
|
||||
if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0)
|
||||
@@ -265,12 +278,31 @@ const onDateRangeChange = (range: { startDate: string; endDate: string; preset:
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const buildUsageListParams = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
exactTotal: boolean
|
||||
): AdminUsageQueryParams => {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
return {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
exact_total: exactTotal,
|
||||
...filters.value,
|
||||
stream: legacyStream === null ? undefined : legacyStream,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}
|
||||
}
|
||||
|
||||
const loadLogs = async () => {
|
||||
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
|
||||
try {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, exact_total: false, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
|
||||
const res = await adminAPI.usage.list(
|
||||
buildUsageListParams(pagination.page, pagination.page_size, false),
|
||||
{ signal: c.signal }
|
||||
)
|
||||
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
|
||||
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
|
||||
}
|
||||
@@ -412,6 +444,12 @@ const resetFilters = () => {
|
||||
}
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
const getRequestTypeLabel = (log: AdminUsageLog): string => {
|
||||
@@ -443,9 +481,10 @@ const exportToExcel = async () => {
|
||||
]
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers])
|
||||
while (true) {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, exact_total: true, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
|
||||
const res = await adminUsageAPI.list(
|
||||
buildUsageListParams(p, 100, true),
|
||||
{ signal: c.signal }
|
||||
)
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
const rows = (res.items || []).map((log: AdminUsageLog) => [
|
||||
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
|
||||
|
||||
@@ -235,7 +235,17 @@
|
||||
|
||||
<!-- Users Table -->
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="users" :loading="loading" :actions-count="7">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="users"
|
||||
:loading="loading"
|
||||
:actions-count="7"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
:sort-storage-key="USER_SORT_STORAGE_KEY"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-email="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
@@ -774,6 +784,25 @@ const columns = computed<Column[]>(() =>
|
||||
const users = ref<AdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
|
||||
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
|
||||
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
|
||||
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'created_at'])
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const parsed = JSON.parse(raw) as { key?: string; order?: string }
|
||||
const key = typeof parsed.key === 'string' ? parsed.key : ''
|
||||
if (!sortable.has(key)) return fallback
|
||||
return {
|
||||
sort_by: key,
|
||||
sort_order: parsed.order === 'asc' ? 'asc' : 'desc'
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
const sortState = reactive(loadInitialSortState())
|
||||
|
||||
// Groups data for the groups column
|
||||
const allGroups = ref<AdminGroup[]>([])
|
||||
@@ -1125,7 +1154,9 @@ const loadUsers = async () => {
|
||||
search: searchQuery.value || undefined,
|
||||
group_name: filters.group || undefined,
|
||||
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
|
||||
include_subscriptions: hasVisibleSubscriptionsColumn.value
|
||||
include_subscriptions: hasVisibleSubscriptionsColumn.value,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// Filter helpers
|
||||
const getAttributeDefinitionName = (attrId: number): string => {
|
||||
const def = attributeDefinitions.value.find(d => d.id === attrId)
|
||||
|
||||
@@ -202,7 +202,6 @@
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10]"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:pageSize="emit('update:pageSize', $event)"
|
||||
/>
|
||||
|
||||
@@ -512,7 +512,6 @@ onMounted(async () => {
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50, 100, 200]"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
|
||||
240
frontend/src/views/admin/orders/AdminOrdersView.vue
Normal file
240
frontend/src/views/admin/orders/AdminOrdersView.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex-1 sm:max-w-64">
|
||||
<input v-model="orderSearch" type="text" :placeholder="t('payment.admin.searchOrders')" class="input" @input="debounceLoadOrders" />
|
||||
</div>
|
||||
<Select v-model="orderFilters.status" :options="statusFilterOptions" class="w-36" @change="loadOrders" />
|
||||
<Select v-model="orderFilters.payment_type" :options="paymentTypeFilterOptions" class="w-40" @change="loadOrders" />
|
||||
<Select v-model="orderFilters.order_type" :options="orderTypeFilterOptions" class="w-36" @change="loadOrders" />
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button @click="loadOrders" :disabled="ordersLoading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="ordersLoading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<OrderTable :orders="orders" :loading="ordersLoading" show-user>
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="showOrderDetail(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dark-600">
|
||||
<Icon name="eye" size="sm" />
|
||||
{{ t('common.view') }}
|
||||
</button>
|
||||
<button v-if="row.status === 'PENDING'" @click="handleCancelOrder(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20">
|
||||
<Icon name="x" size="sm" />
|
||||
{{ t('payment.orders.cancel') }}
|
||||
</button>
|
||||
<button v-if="row.status === 'FAILED'" @click="handleRetryOrder(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('payment.admin.retry') }}
|
||||
</button>
|
||||
<template v-if="row.status === 'REFUND_REQUESTED'">
|
||||
<span v-if="row.refund_amount" class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">${{ row.refund_amount.toFixed(2) }}</span>
|
||||
<button @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
||||
<Icon name="check" size="sm" />
|
||||
{{ t('payment.admin.approveRefund') }}
|
||||
</button>
|
||||
</template>
|
||||
<button v-else-if="row.status === 'REFUND_FAILED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('payment.admin.retryRefund') }}
|
||||
</button>
|
||||
<button v-else-if="row.status === 'COMPLETED' || row.status === 'PARTIALLY_REFUNDED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
<Icon name="dollar" size="sm" />
|
||||
{{ t('payment.admin.refund') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</OrderTable>
|
||||
<Pagination v-if="orderPagination.total > 0" :page="orderPagination.page" :total="orderPagination.total" :page-size="orderPagination.page_size" @update:page="handleOrderPageChange" @update:pageSize="handleOrderPageSizeChange" />
|
||||
</div>
|
||||
|
||||
<!-- Order Detail Dialog -->
|
||||
<BaseDialog :show="showDetailDialog" :title="t('payment.admin.orderDetail')" width="wide" @close="showDetailDialog = false">
|
||||
<div v-if="selectedOrder" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p><p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ selectedOrder.id }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.out_trade_no }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p><OrderStatusBadge :status="selectedOrder.status" /></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">${{ selectedOrder.amount.toFixed(2) }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">${{ selectedOrder.pay_amount.toFixed(2) }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + selectedOrder.payment_type, selectedOrder.payment_type) }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ (selectedOrder.fee_rate * 100).toFixed(1) }}%</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.created_at) }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.expires_at) }}</p></div>
|
||||
<div v-if="selectedOrder.paid_at"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.paid_at) }}</p></div>
|
||||
<div v-if="selectedOrder.refund_amount"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundAmount') }}</p><p class="text-sm font-medium text-red-600 dark:text-red-400">${{ selectedOrder.refund_amount.toFixed(2) }}</p></div>
|
||||
<div v-if="selectedOrder.refund_reason" class="col-span-2"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundReason') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_reason }}</p></div>
|
||||
<!-- Refund request info -->
|
||||
<div v-if="selectedOrder.refund_requested_at" class="col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600">
|
||||
<p class="mb-2 text-xs font-medium text-purple-600 dark:text-purple-400">{{ t('payment.admin.refundRequestInfo') }}</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestedAt') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.refund_requested_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestedBy') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">#{{ selectedOrder.refund_requested_by }}</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestReason') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_request_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Audit Logs -->
|
||||
<div v-if="orderAuditLogs.length > 0" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.auditLogs') }}</p>
|
||||
<div class="max-h-48 space-y-2 overflow-y-auto">
|
||||
<div v-for="log in orderAuditLogs" :key="log.id" class="rounded-lg border border-gray-100 bg-gray-50 p-2.5 dark:border-dark-600 dark:bg-dark-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ log.action }}</span>
|
||||
<span class="text-xs text-gray-400">{{ formatDateTime(log.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="log.detail" class="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{{ log.detail }}</div>
|
||||
<div v-if="log.operator" class="mt-1 text-xs text-gray-400">{{ t('payment.admin.operator') }}: {{ log.operator }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<AdminRefundDialog :show="showRefundDialog" :order="selectedOrder" :submitting="refundSubmitting" @confirm="handleRefund" @cancel="showRefundDialog = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { formatOrderDateTime } from '@/components/payment/orderUtils'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import AdminRefundDialog from '@/components/admin/payment/AdminRefundDialog.vue'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
import OrderTable from '@/components/payment/OrderTable.vue'
|
||||
|
||||
interface AuditLog {
|
||||
id: number
|
||||
action: string
|
||||
detail: string | null
|
||||
operator: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const ordersLoading = ref(false)
|
||||
const orders = ref<PaymentOrder[]>([])
|
||||
const orderSearch = ref('')
|
||||
const orderFilters = reactive({ status: '', payment_type: '', order_type: '' })
|
||||
const orderPagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
const selectedOrder = ref<PaymentOrder | null>(null)
|
||||
const showDetailDialog = ref(false)
|
||||
const showRefundDialog = ref(false)
|
||||
const refundSubmitting = ref(false)
|
||||
const orderAuditLogs = ref<AuditLog[]>([])
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function debounceLoadOrders() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => loadOrders(), 300)
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
ordersLoading.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getOrders({
|
||||
page: orderPagination.page, page_size: orderPagination.page_size,
|
||||
keyword: orderSearch.value || undefined, status: orderFilters.status || undefined,
|
||||
payment_type: orderFilters.payment_type || undefined, order_type: orderFilters.order_type || undefined,
|
||||
})
|
||||
orders.value = res.data.items || []
|
||||
orderPagination.total = res.data.total || 0
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally { ordersLoading.value = false }
|
||||
}
|
||||
|
||||
function handleOrderPageChange(page: number) { orderPagination.page = page; loadOrders() }
|
||||
function handleOrderPageSizeChange(size: number) { orderPagination.page_size = size; orderPagination.page = 1; loadOrders() }
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: t('payment.admin.allStatuses') },
|
||||
{ value: 'PENDING', label: t('payment.status.pending') },
|
||||
{ value: 'PAID', label: t('payment.status.paid') },
|
||||
{ value: 'COMPLETED', label: t('payment.status.completed') },
|
||||
{ value: 'EXPIRED', label: t('payment.status.expired') },
|
||||
{ value: 'CANCELLED', label: t('payment.status.cancelled') },
|
||||
{ value: 'FAILED', label: t('payment.status.failed') },
|
||||
{ value: 'REFUNDED', label: t('payment.status.refunded') },
|
||||
{ value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') },
|
||||
{ value: 'REFUND_FAILED', label: t('payment.status.refund_failed') },
|
||||
])
|
||||
|
||||
const paymentTypeFilterOptions = computed(() => [
|
||||
{ value: '', label: t('payment.admin.allPaymentTypes') },
|
||||
{ value: 'alipay', label: t('payment.methods.alipay') },
|
||||
{ value: 'wxpay', label: t('payment.methods.wxpay') },
|
||||
{ value: 'stripe', label: t('payment.methods.stripe') },
|
||||
])
|
||||
|
||||
const orderTypeFilterOptions = computed(() => [
|
||||
{ value: '', label: t('payment.admin.allOrderTypes') },
|
||||
{ value: 'balance', label: t('payment.admin.balanceOrder') },
|
||||
{ value: 'subscription', label: t('payment.admin.subscriptionOrder') },
|
||||
])
|
||||
|
||||
async function showOrderDetail(order: PaymentOrder) {
|
||||
selectedOrder.value = order
|
||||
orderAuditLogs.value = []
|
||||
showDetailDialog.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getOrder(order.id)
|
||||
const data = res.data as unknown as Record<string, unknown>
|
||||
if (data.order) selectedOrder.value = data.order as PaymentOrder
|
||||
orderAuditLogs.value = ((data.auditLogs || data.audit_logs || []) as unknown) as AuditLog[]
|
||||
} catch (_err: unknown) { /* keep cached order data */ }
|
||||
}
|
||||
|
||||
async function handleCancelOrder(order: PaymentOrder) {
|
||||
try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
}
|
||||
|
||||
async function handleRetryOrder(order: PaymentOrder) {
|
||||
try { await adminPaymentAPI.retryRecharge(order.id); appStore.showSuccess(t('payment.admin.retrySuccess')); loadOrders() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
}
|
||||
|
||||
function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true }
|
||||
|
||||
async function handleRefund(data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }) {
|
||||
if (!selectedOrder.value) return
|
||||
refundSubmitting.value = true
|
||||
try {
|
||||
await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
|
||||
appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { refundSubmitting.value = false }
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string { return formatOrderDateTime(dateStr) }
|
||||
|
||||
onMounted(() => loadOrders())
|
||||
</script>
|
||||
121
frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
Normal file
121
frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Day Switcher -->
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<button
|
||||
v-for="d in DAYS_OPTIONS"
|
||||
:key="d"
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg"
|
||||
:class="days === d
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
|
||||
@click="days = d"
|
||||
>
|
||||
{{ d }}{{ t('payment.admin.daySuffix') }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="loadDashboard" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<template v-else-if="stats">
|
||||
<OrderStatsCards :stats="stats" />
|
||||
<DailyRevenueChart :data="stats.daily_series || []" :loading="loading" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.paymentDistribution') }}</h3>
|
||||
<div v-if="!stats.payment_methods?.length" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="method in stats.payment_methods" :key="method.type" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['inline-block h-3 w-3 rounded-full', methodColor(method.type)]"></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ method.amount.toFixed(2) }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.topUsers') }}</h3>
|
||||
<div v-if="!stats.top_users?.length" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="(user, idx) in stats.top_users" :key="user.user_id" class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ user.amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { DashboardStats } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import OrderStatsCards from '@/components/admin/payment/OrderStatsCards.vue'
|
||||
import DailyRevenueChart from '@/components/admin/payment/DailyRevenueChart.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const DAYS_OPTIONS = [7, 30, 90] as const
|
||||
const days = ref<number>(30)
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats | null>(null)
|
||||
|
||||
function methodColor(type: string): string {
|
||||
const c: Record<string, string> = {
|
||||
alipay: 'bg-blue-500', wxpay: 'bg-green-500',
|
||||
alipay_direct: 'bg-blue-400', wxpay_direct: 'bg-green-400',
|
||||
stripe: 'bg-purple-500',
|
||||
}
|
||||
return c[type] || 'bg-gray-400'
|
||||
}
|
||||
|
||||
function rankClass(idx: number): string {
|
||||
if (idx === 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
if (idx === 1) return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
if (idx === 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400'
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getDashboard(days.value)
|
||||
stats.value = res.data
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(days, () => loadDashboard())
|
||||
onMounted(() => loadDashboard())
|
||||
</script>
|
||||
317
frontend/src/views/admin/orders/AdminPaymentPlansView.vue
Normal file
317
frontend/src/views/admin/orders/AdminPaymentPlansView.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="loadPlans" :disabled="plansLoading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="plansLoading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="openPlanEdit(null)" class="btn btn-primary">{{ t('payment.admin.createPlan') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Plans Table -->
|
||||
<DataTable :columns="planColumns" :data="plans" :loading="plansLoading">
|
||||
<template #cell-name="{ value, row }">
|
||||
<span class="text-sm font-medium" :class="getPlanNameClass(row.group_id)">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-group_id="{ value }">
|
||||
<span v-if="isGroupMissing(value)" class="text-sm">
|
||||
<span class="text-gray-400">#{{ value }}</span>
|
||||
<span class="ml-1 badge badge-danger">{{ t('payment.admin.groupMissing') }}</span>
|
||||
</span>
|
||||
<GroupBadge
|
||||
v-else-if="getGroup(value)"
|
||||
:name="getGroup(value)!.name"
|
||||
:platform="getGroup(value)!.platform"
|
||||
:rate-multiplier="getGroup(value)!.rate_multiplier"
|
||||
/>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</template>
|
||||
<template #cell-price="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-validity_days="{ value, row }">
|
||||
<span class="text-sm">{{ value }} {{ t('payment.admin.' + (row.validity_unit || 'days')) }}</span>
|
||||
</template>
|
||||
<template #cell-for_sale="{ value, row }">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
value ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
@click="toggleForSale(row)"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
value ? 'translate-x-4' : 'translate-x-0'
|
||||
]" />
|
||||
</button>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openPlanEdit(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">
|
||||
<Icon name="edit" size="sm" />
|
||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||
</button>
|
||||
<button @click="confirmDeletePlan(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">
|
||||
<Icon name="trash" size="sm" />
|
||||
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Plan Edit Dialog -->
|
||||
<BaseDialog :show="showPlanDialog" :title="editingPlan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')" width="wide" @close="showPlanDialog = false">
|
||||
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.planName') }}</label>
|
||||
<input v-model="planForm.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.group') }}</label>
|
||||
<Select v-model="planForm.group_id" :options="groupOptions" class="w-full">
|
||||
<template #selected="{ option }">
|
||||
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
|
||||
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<span class="flex-1 truncate text-left" :class="option.platform ? platformTextClass(String(option.platform)) : ''">{{ option.label }}</span>
|
||||
<Icon v-if="selected" name="check" size="sm" class="text-primary-500" :stroke-width="2" />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Info Preview -->
|
||||
<div v-if="selectedGroupInfo" class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<GroupBadge :name="selectedGroupInfo.name" :platform="selectedGroupInfo.platform" :rate-multiplier="selectedGroupInfo.rate_multiplier" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.dailyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.weeklyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.monthlyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div><label class="input-label">{{ t('payment.admin.planDescription') }}</label><textarea v-model="planForm.description" rows="2" class="input"></textarea></div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.price') }}</label><input v-model.number="planForm.price" type="number" step="0.01" min="0" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.validityDays') }}</label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.validityUnit') }}</label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.features') }}</label>
|
||||
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.featuresHint') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.forSale') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
@click="planForm.for_sale = !planForm.for_sale"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
|
||||
]" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="showPlanDialog = false" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="plan-form" :disabled="planSaving" class="btn btn-primary">{{ planSaving ? t('common.saving') : t('common.save') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import adminAPI from '@/api/admin'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { AdminGroup } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import { platformTextClass } from '@/utils/platformColors'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ==================== Groups ====================
|
||||
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups.value = await adminAPI.groups.getAll()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function getGroup(id: number): AdminGroup | undefined {
|
||||
return groups.value.find(g => g.id === id)
|
||||
}
|
||||
|
||||
function isGroupMissing(id: number): boolean {
|
||||
return id > 0 && !groups.value.find(g => g.id === id)
|
||||
}
|
||||
|
||||
function getPlanNameClass(groupId: number): string {
|
||||
const group = getGroup(groupId)
|
||||
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
|
||||
}
|
||||
|
||||
const groupOptions = computed(() => [
|
||||
{ value: 0, label: t('payment.admin.selectGroup'), platform: '' },
|
||||
...groups.value
|
||||
.filter(g => g.subscription_type === 'subscription')
|
||||
.map(g => ({
|
||||
value: g.id,
|
||||
label: `${g.name} — ${g.platform} (${g.rate_multiplier}x)`,
|
||||
platform: g.platform,
|
||||
})),
|
||||
])
|
||||
|
||||
const selectedGroupInfo = computed(() => {
|
||||
if (!planForm.group_id) return null
|
||||
return groups.value.find(g => g.id === planForm.group_id) || null
|
||||
})
|
||||
|
||||
// ==================== Plans ====================
|
||||
|
||||
const plansLoading = ref(false)
|
||||
const plans = ref<SubscriptionPlan[]>([])
|
||||
const showPlanDialog = ref(false)
|
||||
const showDeletePlanDialog = ref(false)
|
||||
const planSaving = ref(false)
|
||||
const editingPlan = ref<SubscriptionPlan | null>(null)
|
||||
const deletingPlanId = ref<number | null>(null)
|
||||
const planForm = reactive({ name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
|
||||
const planFeaturesText = ref('')
|
||||
|
||||
const validityUnitOptions = computed(() => [
|
||||
{ value: 'days', label: t('payment.admin.days') },
|
||||
{ value: 'weeks', label: t('payment.admin.weeks') },
|
||||
{ value: 'months', label: t('payment.admin.months') },
|
||||
])
|
||||
|
||||
const planColumns = computed((): Column[] => [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: t('payment.admin.planName') },
|
||||
{ key: 'group_id', label: t('payment.admin.group') },
|
||||
{ key: 'price', label: t('payment.admin.price') },
|
||||
{ key: 'validity_days', label: t('payment.admin.validityDays') },
|
||||
{ key: 'for_sale', label: t('payment.admin.forSale') },
|
||||
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
|
||||
{ key: 'actions', label: t('common.actions') },
|
||||
])
|
||||
|
||||
async function loadPlans() {
|
||||
plansLoading.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getPlans()
|
||||
// Backend returns features as newline-separated string; parse to array
|
||||
plans.value = (res.data || []).map((p: Omit<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
|
||||
...p,
|
||||
features: typeof p.features === 'string'
|
||||
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
|
||||
: (p.features || []),
|
||||
}))
|
||||
}
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { plansLoading.value = false }
|
||||
}
|
||||
|
||||
function openPlanEdit(plan: SubscriptionPlan | null) {
|
||||
editingPlan.value = plan
|
||||
if (plan) {
|
||||
Object.assign(planForm, { name: plan.name, group_id: plan.group_id, description: plan.description, price: plan.price, original_price: plan.original_price || 0, validity_days: plan.validity_days, validity_unit: plan.validity_unit || 'days', for_sale: plan.for_sale, sort_order: plan.sort_order })
|
||||
planFeaturesText.value = (plan.features || []).join('\n')
|
||||
} else {
|
||||
Object.assign(planForm, { name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
|
||||
planFeaturesText.value = ''
|
||||
}
|
||||
showPlanDialog.value = true
|
||||
}
|
||||
|
||||
/** Build request payload with snake_case keys matching backend JSON tags */
|
||||
function buildPlanPayload() {
|
||||
const features = planFeaturesText.value.split('\n').map(f => f.trim()).filter(Boolean).join('\n')
|
||||
return {
|
||||
name: planForm.name,
|
||||
group_id: planForm.group_id,
|
||||
description: planForm.description,
|
||||
price: planForm.price,
|
||||
original_price: planForm.original_price || 0,
|
||||
validity_days: planForm.validity_days,
|
||||
validity_unit: planForm.validity_unit,
|
||||
for_sale: planForm.for_sale,
|
||||
sort_order: planForm.sort_order,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePlan() {
|
||||
planSaving.value = true
|
||||
try {
|
||||
const data = buildPlanPayload()
|
||||
if (editingPlan.value) { await adminPaymentAPI.updatePlan(editingPlan.value.id, data) }
|
||||
else { await adminPaymentAPI.createPlan(data) }
|
||||
appStore.showSuccess(t('common.saved')); showPlanDialog.value = false; loadPlans()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { planSaving.value = false }
|
||||
}
|
||||
|
||||
/** Quick toggle for_sale from the list */
|
||||
async function toggleForSale(plan: SubscriptionPlan) {
|
||||
try {
|
||||
await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale })
|
||||
plan.for_sale = !plan.for_sale
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeletePlan(plan: SubscriptionPlan) { deletingPlanId.value = plan.id; showDeletePlanDialog.value = true }
|
||||
async function handleDeletePlan() {
|
||||
if (!deletingPlanId.value) return
|
||||
try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
loadPlans()
|
||||
})
|
||||
</script>
|
||||
@@ -49,7 +49,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="apiKeys" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="apiKeys"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-key="{ value, row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="code text-xs">
|
||||
@@ -1114,6 +1122,10 @@ const pagination = ref({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = ref({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
// Filter state
|
||||
const filterSearch = ref('')
|
||||
@@ -1277,10 +1289,18 @@ const loadApiKeys = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Build filters
|
||||
const filters: { search?: string; status?: string; group_id?: number | string } = {}
|
||||
const filters: {
|
||||
search?: string
|
||||
status?: string
|
||||
group_id?: number | string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
} = {}
|
||||
if (filterSearch.value) filters.search = filterSearch.value
|
||||
if (filterStatus.value) filters.status = filterStatus.value
|
||||
if (filterGroupId.value !== '') filters.group_id = filterGroupId.value
|
||||
filters.sort_by = sortState.value.sort_by
|
||||
filters.sort_order = sortState.value.sort_order
|
||||
|
||||
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, filters, {
|
||||
signal
|
||||
@@ -1360,6 +1380,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.value.sort_by = key
|
||||
sortState.value.sort_order = order
|
||||
pagination.value.page = 1
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
|
||||
|
||||
203
frontend/src/views/user/PaymentQRCodeView.vue
Normal file
203
frontend/src/views/user/PaymentQRCodeView.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="mx-auto flex max-w-md flex-col items-center space-y-6 py-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ qrUrl ? scanTitle : t('payment.qr.payInNewWindow') }}
|
||||
</h2>
|
||||
<div v-if="qrUrl" class="rounded-2xl bg-white p-6 shadow-lg dark:bg-dark-800">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
</div>
|
||||
<!-- Scan prompt for QR code -->
|
||||
<p v-if="qrUrl && !expired && scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ scanHint }}
|
||||
</p>
|
||||
<div v-if="expired" class="text-center">
|
||||
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
|
||||
<button class="btn btn-primary mt-4" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : t('payment.qr.payInNewWindowHint') }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
<a v-if="payUrl && !qrUrl && !expired" :href="payUrl" target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-primary w-full py-3">
|
||||
{{ t('payment.qr.openPayWindow') }}
|
||||
</a>
|
||||
<!-- Cancel button -->
|
||||
<button v-if="!expired && orderId" class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { useAppStore } from '@/stores'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const qrUrl = ref('')
|
||||
const payUrl = ref('')
|
||||
const orderId = ref(0)
|
||||
const remainingSeconds = ref(0)
|
||||
const expired = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const paymentType = ref('')
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
const m = Math.floor(remainingSeconds.value / 60)
|
||||
const s = remainingSeconds.value % 60
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
const isAlipay = computed(() => paymentType.value.includes('alipay'))
|
||||
const isWxpay = computed(() => paymentType.value.includes('wxpay'))
|
||||
|
||||
const scanTitle = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpay')
|
||||
return t('payment.qr.scanToPay')
|
||||
})
|
||||
|
||||
const scanHint = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
|
||||
return ''
|
||||
})
|
||||
|
||||
function getLogoForType(): string | null {
|
||||
if (isAlipay.value) return alipayIcon
|
||||
if (isWxpay.value) return wxpayIcon
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
|
||||
// Use high error correction to support logo overlay
|
||||
const logoSrc = getLogoForType()
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: logoSrc ? 'H' : 'M',
|
||||
})
|
||||
|
||||
if (!logoSrc) return
|
||||
|
||||
// Draw logo in center of QR code
|
||||
const canvas = qrCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const img = new Image()
|
||||
img.src = logoSrc
|
||||
img.onload = () => {
|
||||
const logoSize = 48
|
||||
const x = (canvas.width - logoSize) / 2
|
||||
const y = (canvas.height - logoSize) / 2
|
||||
// White background with rounded corners
|
||||
const pad = 5
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
const r = 6
|
||||
ctx.moveTo(x - pad + r, y - pad)
|
||||
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
|
||||
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
|
||||
ctx.fill()
|
||||
// Draw logo
|
||||
ctx.drawImage(img, x, y, logoSize, logoSize)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!orderId.value) return
|
||||
const order = await paymentStore.pollOrderStatus(orderId.value)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } })
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
return
|
||||
}
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
cleanup()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!orderId.value || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(orderId.value)
|
||||
cleanup()
|
||||
router.push('/purchase')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
||||
}
|
||||
|
||||
watch(qrUrl, () => renderQR())
|
||||
|
||||
onMounted(() => {
|
||||
orderId.value = Number(route.query.order_id) || 0
|
||||
qrUrl.value = String(route.query.qr || '')
|
||||
payUrl.value = String(route.query.pay_url || '')
|
||||
paymentType.value = String(route.query.payment_type || '')
|
||||
|
||||
// Calculate countdown from expiresAt
|
||||
const expiresAtStr = String(route.query.expires_at || '')
|
||||
let seconds = 30 * 60 // fallback: 30 minutes
|
||||
if (expiresAtStr) {
|
||||
const expiresAt = new Date(expiresAtStr)
|
||||
const now = new Date()
|
||||
seconds = Math.floor((expiresAt.getTime() - now.getTime()) / 1000)
|
||||
}
|
||||
startCountdown(seconds)
|
||||
pollTimer = setInterval(pollStatus, 3000)
|
||||
renderQR()
|
||||
})
|
||||
|
||||
onUnmounted(() => cleanup())
|
||||
</script>
|
||||
165
frontend/src/views/user/PaymentResultView.vue
Normal file
165
frontend/src/views/user/PaymentResultView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-dark-900">
|
||||
<div class="w-full max-w-md space-y-6">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Status Icon -->
|
||||
<div class="text-center">
|
||||
<div v-if="isSuccess"
|
||||
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="h-10 w-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else
|
||||
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-10 w-10 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ isSuccess ? t('payment.result.success') : t('payment.result.failed') }}
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Order Info -->
|
||||
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ order.id }}</span>
|
||||
</div>
|
||||
<div v-if="order.out_trade_no" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">¥{{ order.pay_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + order.payment_type, order.payment_type) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</span>
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- EasyPay return info (when no order loaded) -->
|
||||
<div v-else-if="returnInfo" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div v-if="returnInfo.outTradeNo" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
|
||||
</div>
|
||||
<div v-if="returnInfo.money" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">¥{{ returnInfo.money }}</span>
|
||||
</div>
|
||||
<div v-if="returnInfo.type" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + returnInfo.type, returnInfo.type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button class="btn btn-secondary flex-1" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
<button class="btn btn-primary flex-1" @click="router.push('/orders')">{{ t('payment.result.viewOrders') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const paymentStore = usePaymentStore()
|
||||
|
||||
const order = ref<PaymentOrder | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
interface ReturnInfo {
|
||||
outTradeNo: string
|
||||
money: string
|
||||
type: string
|
||||
tradeStatus: string
|
||||
}
|
||||
const returnInfo = ref<ReturnInfo | null>(null)
|
||||
|
||||
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
|
||||
|
||||
const isSuccess = computed(() => {
|
||||
// Always prioritize actual order status from backend
|
||||
if (order.value) {
|
||||
return SUCCESS_STATUSES.has(order.value.status)
|
||||
}
|
||||
// Fallback only when order not loaded
|
||||
if (route.query.status === 'success') return true
|
||||
if (route.query.trade_status === 'TRADE_SUCCESS') return true
|
||||
return false
|
||||
})
|
||||
|
||||
/** Extract numeric order ID from out_trade_no like "sub2_46" → 46 */
|
||||
function parseOutTradeNo(outTradeNo: string): number {
|
||||
const match = outTradeNo.match(/_(\d+)$/)
|
||||
return match ? Number(match[1]) : 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Try order_id first (internal navigation from QRCode/Stripe pages)
|
||||
let orderId = Number(route.query.order_id) || 0
|
||||
const outTradeNo = String(route.query.out_trade_no || '')
|
||||
|
||||
// Fallback: EasyPay return URL with out_trade_no
|
||||
if (!orderId && outTradeNo) {
|
||||
orderId = parseOutTradeNo(outTradeNo)
|
||||
// Store return info for display when order lookup fails
|
||||
returnInfo.value = {
|
||||
outTradeNo,
|
||||
money: String(route.query.money || ''),
|
||||
type: String(route.query.type || ''),
|
||||
tradeStatus: String(route.query.trade_status || ''),
|
||||
}
|
||||
}
|
||||
|
||||
// Verify payment via public endpoint (works without login)
|
||||
if (outTradeNo) {
|
||||
try {
|
||||
const result = await paymentAPI.verifyOrderPublic(outTradeNo)
|
||||
order.value = result.data
|
||||
} catch (_err: unknown) {
|
||||
// Public verify failed, try authenticated endpoint if logged in
|
||||
try {
|
||||
const result = await paymentAPI.verifyOrder(outTradeNo)
|
||||
order.value = result.data
|
||||
} catch (_e: unknown) { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Normal order lookup by ID (if verify didn't load the order)
|
||||
if (!order.value && orderId) {
|
||||
try {
|
||||
order.value = await paymentStore.pollOrderStatus(orderId)
|
||||
} catch (_err: unknown) {
|
||||
// Order lookup failed, will show returnInfo fallback
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
631
frontend/src/views/user/PaymentView.vue
Normal file
631
frontend/src/views/user/PaymentView.vue
Normal file
@@ -0,0 +1,631 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Tab Switcher (hide during payment and subscription confirm) -->
|
||||
<div v-if="tabs.length > 1 && paymentPhase === 'select' && !selectedPlan" class="flex space-x-1 rounded-xl bg-gray-100 p-1 dark:bg-dark-800">
|
||||
<button v-for="tab in tabs" :key="tab.key"
|
||||
class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all"
|
||||
:class="activeTab === tab.key ? 'bg-white text-gray-900 shadow dark:bg-dark-700 dark:text-white' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
@click="activeTab = tab.key">{{ tab.label }}</button>
|
||||
</div>
|
||||
<!-- Payment in progress (shared by recharge and subscription) -->
|
||||
<template v-if="paymentPhase === 'paying'">
|
||||
<PaymentStatusPanel
|
||||
:order-id="paymentState.orderId"
|
||||
:qr-code="paymentState.qrCode"
|
||||
:expires-at="paymentState.expiresAt"
|
||||
:payment-type="paymentState.paymentType"
|
||||
:pay-url="paymentState.payUrl"
|
||||
:order-type="paymentState.orderType"
|
||||
@done="onPaymentDone"
|
||||
@success="onPaymentSuccess"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="paymentPhase === 'stripe'">
|
||||
<StripePaymentInline
|
||||
:order-id="paymentState.orderId"
|
||||
:client-secret="paymentState.clientSecret"
|
||||
:publishable-key="checkout.stripe_publishable_key"
|
||||
:pay-amount="paymentState.payAmount"
|
||||
@success="onPaymentSuccess"
|
||||
@done="onStripeDone"
|
||||
@back="resetPayment"
|
||||
@redirect="onStripeRedirect"
|
||||
/>
|
||||
</template>
|
||||
<!-- Tab content (select phase) -->
|
||||
<template v-else>
|
||||
<!-- Top-up Tab -->
|
||||
<template v-if="activeTab === 'recharge'">
|
||||
<!-- Recharge Account Card -->
|
||||
<div class="card p-5">
|
||||
<p class="text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.rechargeAccount') }}</p>
|
||||
<p class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ user?.username || '' }}</p>
|
||||
<p class="mt-0.5 text-sm font-medium text-green-600 dark:text-green-400">{{ t('payment.currentBalance') }}: {{ user?.balance?.toFixed(2) || '0.00' }}</p>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length === 0" class="card py-16 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.notAvailable') }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="card p-6">
|
||||
<AmountInput
|
||||
v-model="amount"
|
||||
:amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
|
||||
:min="globalMinAmount"
|
||||
:max="globalMaxAmount"
|
||||
/>
|
||||
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length >= 1" class="card p-6">
|
||||
<PaymentMethodSelector
|
||||
:methods="methodOptions"
|
||||
:selected="selectedMethod"
|
||||
@select="selectedMethod = $event"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="feeRate > 0 && validAmount > 0" class="card p-6">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ validAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ feeAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ totalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
|
||||
</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Subscribe Tab -->
|
||||
<template v-else-if="activeTab === 'subscription'">
|
||||
<!-- Subscription confirm (inline, replaces plan list) -->
|
||||
<template v-if="selectedPlan">
|
||||
<div class="card p-5">
|
||||
<!-- Header: platform badge + plan name -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span :class="['rounded-md border px-2 py-0.5 text-xs font-medium', planBadgeClass]">
|
||||
{{ platformLabel(selectedPlan.group_platform || '') }}
|
||||
</span>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ selectedPlan.name }}</h3>
|
||||
</div>
|
||||
<!-- Price -->
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
|
||||
${{ selectedPlan.original_price }}
|
||||
</span>
|
||||
<span :class="['text-3xl font-bold', planTextClass]">${{ selectedPlan.price }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<p v-if="selectedPlan.description" class="mt-2 text-sm leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
{{ selectedPlan.description }}
|
||||
</p>
|
||||
<!-- Rate + Limits grid -->
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.rate') }}</span>
|
||||
<div class="flex items-baseline">
|
||||
<span :class="['text-lg font-bold', planTextClass]">×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.daily_limit_usd != null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.dailyLimit') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.daily_limit_usd }}</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.weekly_limit_usd != null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.weeklyLimit') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.weekly_limit_usd }}</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.monthly_limit_usd != null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.monthlyLimit') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.monthly_limit_usd }}</div>
|
||||
</div>
|
||||
<div v-if="selectedPlan.daily_limit_usd == null && selectedPlan.weekly_limit_usd == null && selectedPlan.monthly_limit_usd == null">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.quota') }}</span>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">{{ t('payment.planCard.unlimited') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length >= 1" class="card p-6">
|
||||
<PaymentMethodSelector
|
||||
:methods="subMethodOptions"
|
||||
:selected="selectedMethod"
|
||||
@select="selectedMethod = $event"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="feeRate > 0 && selectedPlan.price > 0" class="card p-6">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ selectedPlan.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ subFeeAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ subTotalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Plan list -->
|
||||
<template v-else>
|
||||
<div v-if="checkout.plans.length === 0" class="card py-16 text-center">
|
||||
<Icon name="gift" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" />
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</p>
|
||||
</div>
|
||||
<div v-else :class="planGridClass">
|
||||
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlan" />
|
||||
</div>
|
||||
<!-- Active subscriptions (compact, below plan list) -->
|
||||
<div v-if="activeSubscriptions.length > 0">
|
||||
<p class="mb-2 text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.activeSubscription') }}</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="sub in activeSubscriptions" :key="sub.id"
|
||||
class="flex items-center gap-3 rounded-xl border border-gray-100 bg-white px-3 py-2 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div :class="['h-6 w-1 shrink-0 rounded-full', platformAccentBarClass(sub.group?.platform || '')]" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-xs font-semibold text-gray-900 dark:text-white">{{ sub.group?.name || `Group #${sub.group_id}` }}</span>
|
||||
<span :class="['shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium', platformBadgeLightClass(sub.group?.platform || '')]">{{ platformLabel(sub.group?.platform || '') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-3 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
<span>{{ t('payment.planCard.rate') }}: ×{{ sub.group?.rate_multiplier ?? 1 }}</span>
|
||||
<span v-if="sub.group?.daily_limit_usd == null && sub.group?.weekly_limit_usd == null && sub.group?.monthly_limit_usd == null">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
|
||||
<span v-if="sub.expires_at">{{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }}</span>
|
||||
<span v-else>{{ t('userSubscriptions.noExpiration') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-success shrink-0 text-[10px]">{{ t('userSubscriptions.status.active') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="(checkout.help_text || checkout.help_image_url) && paymentPhase === 'select' && !selectedPlan" class="card p-4">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<img v-if="checkout.help_image_url" :src="checkout.help_image_url" alt=""
|
||||
class="h-40 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
|
||||
@click="previewImage = checkout.help_image_url" />
|
||||
<p v-if="checkout.help_text" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Renewal Plan Selection Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showRenewalModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeRenewalModal">
|
||||
<div class="relative w-full max-w-lg rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-dark-700 dark:bg-dark-900">
|
||||
<!-- Close button -->
|
||||
<button class="absolute right-4 top-4 rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-200" @click="closeRenewalModal">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.selectPlan') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<SubscriptionPlanCard v-for="plan in renewalPlans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlanFromModal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<!-- Image Preview Overlay -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="previewImage" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm" @click="previewImage = ''">
|
||||
<img :src="previewImage" alt="" class="max-h-[85vh] max-w-[90vw] rounded-xl object-contain shadow-2xl" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const paymentStore = usePaymentStore()
|
||||
const subscriptionStore = useSubscriptionStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
|
||||
|
||||
function getDaysRemaining(expiresAt: string): number {
|
||||
const diff = new Date(expiresAt).getTime() - Date.now()
|
||||
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const activeTab = ref<'recharge' | 'subscription'>('recharge')
|
||||
const amount = ref<number | null>(null)
|
||||
const selectedMethod = ref('')
|
||||
const selectedPlan = ref<SubscriptionPlan | null>(null)
|
||||
const previewImage = ref('')
|
||||
|
||||
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
|
||||
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
|
||||
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
|
||||
|
||||
function resetPayment() {
|
||||
paymentPhase.value = 'select'
|
||||
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
|
||||
}
|
||||
|
||||
function onPaymentDone() {
|
||||
const wasSubscription = paymentState.value.orderType === 'subscription'
|
||||
resetPayment()
|
||||
selectedPlan.value = null
|
||||
if (wasSubscription) {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onPaymentSuccess() {
|
||||
authStore.refreshUser()
|
||||
if (paymentState.value.orderType === 'subscription') {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onStripeDone() {
|
||||
const wasSubscription = paymentState.value.orderType === 'subscription'
|
||||
resetPayment()
|
||||
selectedPlan.value = null
|
||||
if (wasSubscription) {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onStripeRedirect(orderId: number, payUrl: string) {
|
||||
paymentState.value = { ...paymentState.value, orderId, payUrl, qrCode: '' }
|
||||
paymentPhase.value = 'paying'
|
||||
}
|
||||
|
||||
// All checkout data from single API call
|
||||
const checkout = ref<CheckoutInfoResponse>({
|
||||
methods: {}, global_min: 0, global_max: 0,
|
||||
plans: [], balance_disabled: false, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
const result: { key: 'recharge' | 'subscription'; label: string }[] = []
|
||||
if (!checkout.value.balance_disabled) result.push({ key: 'recharge', label: t('payment.tabTopUp') })
|
||||
result.push({ key: 'subscription', label: t('payment.tabSubscribe') })
|
||||
return result
|
||||
})
|
||||
|
||||
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
|
||||
const validAmount = computed(() => amount.value ?? 0)
|
||||
|
||||
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
||||
const planGridClass = computed(() => {
|
||||
const n = checkout.value.plans.length
|
||||
if (n <= 2) return 'grid grid-cols-1 gap-5 sm:grid-cols-2'
|
||||
return 'grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3'
|
||||
})
|
||||
|
||||
// Check if an amount fits a method's [min, max]. 0 = no limit.
|
||||
function amountFitsMethod(amt: number, methodType: string): boolean {
|
||||
if (amt <= 0) return true
|
||||
const ml = checkout.value.methods[methodType]
|
||||
if (!ml) return false
|
||||
if (ml.single_min > 0 && amt < ml.single_min) return false
|
||||
if (ml.single_max > 0 && amt > ml.single_max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Global range for AmountInput (union of all methods, precomputed by backend)
|
||||
const globalMinAmount = computed(() => checkout.value.global_min)
|
||||
const globalMaxAmount = computed(() => checkout.value.global_max)
|
||||
|
||||
// Selected method's limits (for validation and error messages)
|
||||
const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])
|
||||
|
||||
const methodOptions = computed<PaymentMethodOption[]>(() =>
|
||||
enabledMethods.value.map((type) => {
|
||||
const ml = checkout.value.methods[type]
|
||||
return {
|
||||
type,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
available: ml?.available !== false && amountFitsMethod(validAmount.value, type),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const feeRate = computed(() => selectedLimit.value?.fee_rate ?? 0)
|
||||
const feeAmount = computed(() =>
|
||||
feeRate.value > 0 && validAmount.value > 0
|
||||
? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100
|
||||
: 0
|
||||
)
|
||||
const totalAmount = computed(() =>
|
||||
feeRate.value > 0 && validAmount.value > 0
|
||||
? Math.round((validAmount.value + feeAmount.value) * 100) / 100
|
||||
: validAmount.value
|
||||
)
|
||||
|
||||
const amountError = computed(() => {
|
||||
if (validAmount.value <= 0) return ''
|
||||
// No method can handle this amount
|
||||
if (!enabledMethods.value.some((m) => amountFitsMethod(validAmount.value, m))) {
|
||||
return t('payment.amountNoMethod')
|
||||
}
|
||||
// Selected method can't handle this amount (but others can)
|
||||
const ml = selectedLimit.value
|
||||
if (ml) {
|
||||
if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: ml.single_min })
|
||||
if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: ml.single_max })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
validAmount.value > 0
|
||||
&& amountFitsMethod(validAmount.value, selectedMethod.value)
|
||||
&& selectedLimit.value?.available !== false
|
||||
)
|
||||
|
||||
// Subscription-specific: method options based on plan price
|
||||
const subMethodOptions = computed<PaymentMethodOption[]>(() => {
|
||||
const planPrice = selectedPlan.value?.price ?? 0
|
||||
return enabledMethods.value.map((type) => {
|
||||
const ml = checkout.value.methods[type]
|
||||
return {
|
||||
type,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
available: ml?.available !== false && amountFitsMethod(planPrice, type),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const subFeeAmount = computed(() => {
|
||||
const price = selectedPlan.value?.price ?? 0
|
||||
if (feeRate.value <= 0 || price <= 0) return 0
|
||||
return Math.ceil(((price * feeRate.value) / 100) * 100) / 100
|
||||
})
|
||||
|
||||
const subTotalAmount = computed(() => {
|
||||
const price = selectedPlan.value?.price ?? 0
|
||||
if (feeRate.value <= 0 || price <= 0) return price
|
||||
return Math.round((price + subFeeAmount.value) * 100) / 100
|
||||
})
|
||||
|
||||
const canSubmitSubscription = computed(() =>
|
||||
selectedPlan.value !== null
|
||||
&& amountFitsMethod(selectedPlan.value.price, selectedMethod.value)
|
||||
&& selectedLimit.value?.available !== false
|
||||
)
|
||||
|
||||
// Auto-switch to first available method when current selection can't handle the amount
|
||||
watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => {
|
||||
if (amt <= 0 || amountFitsMethod(amt, method)) return
|
||||
const available = enabledMethods.value.find((m) => amountFitsMethod(amt, m))
|
||||
if (available) selectedMethod.value = available
|
||||
})
|
||||
|
||||
// Payment button class: follows selected payment method color
|
||||
const paymentButtonClass = computed(() => {
|
||||
const m = selectedMethod.value
|
||||
if (!m) return 'btn-primary'
|
||||
if (m.includes('alipay')) return 'btn-alipay'
|
||||
if (m.includes('wxpay')) return 'btn-wxpay'
|
||||
if (m === 'stripe') return 'btn-stripe'
|
||||
return 'btn-primary'
|
||||
})
|
||||
|
||||
// Subscription confirm: platform accent colors (clean card, no gradient)
|
||||
const planBadgeClass = computed(() => platformBadgeClass(selectedPlan.value?.group_platform || ''))
|
||||
const planTextClass = computed(() => platformTextClass(selectedPlan.value?.group_platform || ''))
|
||||
|
||||
// Renewal modal state
|
||||
const showRenewalModal = ref(false)
|
||||
const renewGroupId = ref<number | null>(null)
|
||||
const renewalPlans = computed(() => {
|
||||
if (renewGroupId.value == null) return []
|
||||
return checkout.value.plans.filter(p => p.group_id === renewGroupId.value)
|
||||
})
|
||||
|
||||
const planValiditySuffix = computed(() => {
|
||||
if (!selectedPlan.value) return ''
|
||||
const u = selectedPlan.value.validity_unit || 'day'
|
||||
if (u === 'month') return t('payment.perMonth')
|
||||
if (u === 'year') return t('payment.perYear')
|
||||
return `${selectedPlan.value.validity_days}${t('payment.days')}`
|
||||
})
|
||||
|
||||
function selectPlan(plan: SubscriptionPlan) {
|
||||
selectedPlan.value = plan
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function selectPlanFromModal(plan: SubscriptionPlan) {
|
||||
showRenewalModal.value = false
|
||||
renewGroupId.value = null
|
||||
selectedPlan.value = plan
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function closeRenewalModal() {
|
||||
showRenewalModal.value = false
|
||||
renewGroupId.value = null
|
||||
}
|
||||
|
||||
async function handleSubmitRecharge() {
|
||||
if (!canSubmit.value || submitting.value) return
|
||||
await createOrder(validAmount.value, 'balance')
|
||||
}
|
||||
|
||||
async function confirmSubscribe() {
|
||||
if (!selectedPlan.value || submitting.value) return
|
||||
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
|
||||
}
|
||||
|
||||
async function createOrder(orderAmount: number, orderType: string, planId?: number) {
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await paymentStore.createOrder({
|
||||
amount: orderAmount,
|
||||
payment_type: selectedMethod.value,
|
||||
order_type: orderType,
|
||||
plan_id: planId,
|
||||
})
|
||||
const openWindow = (url: string) => {
|
||||
const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
if (!win || win.closed) {
|
||||
window.location.href = url
|
||||
}
|
||||
}
|
||||
if (result.client_secret) {
|
||||
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: result.client_secret, payAmount: result.pay_amount,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'stripe'
|
||||
} else if (isMobileDevice() && result.pay_url) {
|
||||
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
window.location.href = result.pay_url
|
||||
return
|
||||
} else if (result.qr_code) {
|
||||
// QR mode: show QR code inline
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: result.qr_code,
|
||||
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else if (result.pay_url) {
|
||||
// Redirect/popup mode: open payment URL, show waiting state inline
|
||||
openWindow(result.pay_url)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else {
|
||||
errorMessage.value = t('payment.result.failed')
|
||||
appStore.showError(errorMessage.value)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as Record<string, unknown>
|
||||
if (apiErr.reason === 'TOO_MANY_PENDING') {
|
||||
const metadata = apiErr.metadata as Record<string, unknown> | undefined
|
||||
errorMessage.value = t('payment.errors.tooManyPending', { max: metadata?.max || '' })
|
||||
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
|
||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||
} else {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
}
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await paymentAPI.getCheckoutInfo()
|
||||
checkout.value = res.data
|
||||
if (enabledMethods.value.length) {
|
||||
const order: readonly string[] = METHOD_ORDER
|
||||
const sorted = [...enabledMethods.value].sort((a, b) => {
|
||||
const ai = order.indexOf(a)
|
||||
const bi = order.indexOf(b)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
selectedMethod.value = sorted[0]
|
||||
}
|
||||
if (checkout.value.balance_disabled) {
|
||||
activeTab.value = 'subscription'
|
||||
}
|
||||
// Handle renewal navigation: ?tab=subscription&group=123
|
||||
if (route.query.tab === 'subscription') {
|
||||
activeTab.value = 'subscription'
|
||||
if (route.query.group) {
|
||||
const groupId = Number(route.query.group)
|
||||
const groupPlans = checkout.value.plans.filter(p => p.group_id === groupId)
|
||||
if (groupPlans.length === 1) {
|
||||
selectedPlan.value = groupPlans[0]
|
||||
} else if (groupPlans.length > 1) {
|
||||
renewGroupId.value = groupId
|
||||
showRenewalModal.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { loading.value = false }
|
||||
// Fetch active subscriptions (uses cache, non-blocking)
|
||||
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="purchase-page-layout">
|
||||
<div class="card flex-1 min-h-0 overflow-hidden">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!purchaseEnabled"
|
||||
class="flex h-full items-center justify-center p-10 text-center"
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="creditCard" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.notEnabledTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.notEnabledDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isValidUrl"
|
||||
class="flex h-full items-center justify-center p-10 text-center"
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="link" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.notConfiguredTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.notConfiguredDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="purchase-embed-shell">
|
||||
<a
|
||||
:href="purchaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm purchase-open-fab"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ t('purchase.openInNewTab') }}
|
||||
</a>
|
||||
<iframe
|
||||
:src="purchaseUrl"
|
||||
class="purchase-embed-frame"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const purchaseTheme = ref<'light' | 'dark'>('light')
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
const purchaseEnabled = computed(() => {
|
||||
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
||||
})
|
||||
|
||||
const purchaseUrl = computed(() => {
|
||||
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
||||
return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value, locale.value)
|
||||
})
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const url = purchaseUrl.value
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
purchaseTheme.value = detectTheme()
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
themeObserver = new MutationObserver(() => {
|
||||
purchaseTheme.value = detectTheme()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
}
|
||||
|
||||
if (appStore.publicSettingsLoaded) return
|
||||
loading.value = true
|
||||
try {
|
||||
await appStore.fetchPublicSettings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect()
|
||||
themeObserver = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.purchase-page-layout {
|
||||
@apply flex flex-col;
|
||||
height: calc(100vh - 64px - 4rem);
|
||||
}
|
||||
|
||||
.purchase-embed-shell {
|
||||
@apply relative;
|
||||
@apply h-full w-full overflow-hidden rounded-2xl;
|
||||
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
.purchase-open-fab {
|
||||
@apply absolute right-3 top-3 z-10;
|
||||
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:supports-[backdrop-filter]:bg-dark-800/80;
|
||||
}
|
||||
|
||||
.purchase-embed-frame {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
288
frontend/src/views/user/StripePaymentView.vue
Normal file
288
frontend/src/views/user/StripePaymentView.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<component :is="isPopup ? 'div' : AppLayout" :class="isPopup ? 'min-h-screen bg-gray-50 dark:bg-dark-900' : ''">
|
||||
<div class="mx-auto max-w-lg space-y-6 py-8" :class="isPopup ? 'px-4' : ''">
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div v-else-if="initError" class="card p-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<Icon name="exclamationCircle" size="xl" class="text-red-500" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.stripeLoadFailed') }}</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ initError }}</p>
|
||||
<button class="btn btn-primary mt-6" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Amount header -->
|
||||
<div v-if="order" class="card overflow-hidden">
|
||||
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-6 text-center">
|
||||
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-white">¥{{ order.pay_amount.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WeChat QR Code display -->
|
||||
<template v-if="wechatQrUrl">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.scanWxpay') }}</p>
|
||||
<div class="relative rounded-lg border-2 border-[#2BB741] bg-green-50 p-4 dark:border-[#2BB741]/70 dark:bg-green-950/20">
|
||||
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56 rounded" />
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span class="rounded-full bg-[#2BB741] p-2 shadow ring-2 ring-white">
|
||||
<svg class="h-5 w-5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.636 4.35c-2.084 0-3.993.672-5.363 1.844-1.188.982-2.004 2.308-2.004 3.862 0 1.207.546 2.355 1.483 3.285.114.113.238.213.358.321l-.105.42c-.021.084-.042.17-.042.253 0 .168.126.258.282.258.065 0 .126-.025.18-.058l1.27-.765a.69.69 0 0 1 .58-.086c.96.282 1.99.437 3.043.437 2.633 0 5.03-.972 6.4-2.5.782-.87 1.258-1.901 1.258-3.006 0-3.328-3.325-6.006-7.34-6.006zm-3.21 3.09c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957zm4.739 0c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.scanWxpayHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Alipay redirecting state -->
|
||||
<template v-else-if="redirecting">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-[#00AEEF] border-t-transparent"></div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success state -->
|
||||
<template v-else-if="stripeSuccess">
|
||||
<div class="card p-6 text-center">
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Icon name="check" size="lg" class="text-green-500" />
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.stripeSuccessProcessing') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fallback: full Payment Element (no method param or unknown method) -->
|
||||
<template v-else-if="showPaymentElement">
|
||||
<div class="card p-6">
|
||||
<div id="stripe-payment-element" class="min-h-[200px]"></div>
|
||||
<p v-if="stripeError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
|
||||
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="stripeSubmitting || !stripeReady" @click="handleGenericPay">
|
||||
<span v-if="stripeSubmitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.stripePay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button class="btn btn-secondary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="stripeError && !showPaymentElement" class="card p-4">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
|
||||
<button class="btn btn-secondary mt-3 w-full" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const paymentStore = usePaymentStore()
|
||||
|
||||
// Popup mode: skip AppLayout when opened with a specific method (alipay/wechat_pay)
|
||||
const isPopup = computed(() => !!route.query.method)
|
||||
|
||||
const loading = ref(true)
|
||||
const initError = ref('')
|
||||
const stripeError = ref('')
|
||||
const stripeSubmitting = ref(false)
|
||||
const stripeSuccess = ref(false)
|
||||
const stripeReady = ref(false)
|
||||
const order = ref<PaymentOrder | null>(null)
|
||||
const wechatQrUrl = ref('')
|
||||
const redirecting = ref(false)
|
||||
const showPaymentElement = ref(false)
|
||||
|
||||
let stripeInstance: Stripe | null = null
|
||||
let elementsInstance: StripeElements | null = null
|
||||
let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const orderId = Number(route.query.order_id)
|
||||
const clientSecret = String(route.query.client_secret || '')
|
||||
const method = String(route.query.method || '')
|
||||
|
||||
if (!orderId || !clientSecret) {
|
||||
loading.value = false
|
||||
initError.value = t('payment.stripeMissingParams')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await paymentAPI.getOrder(orderId)
|
||||
order.value = res.data
|
||||
|
||||
await paymentStore.fetchConfig()
|
||||
const publishableKey = paymentStore.config?.stripe_publishable_key
|
||||
if (!publishableKey) { initError.value = t('payment.stripeNotConfigured'); return }
|
||||
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(publishableKey)
|
||||
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
stripeInstance = stripe
|
||||
loading.value = false
|
||||
|
||||
// Direct confirm for specific methods (no Payment Element needed)
|
||||
if (method === 'alipay') {
|
||||
await confirmAlipay(stripe, clientSecret, orderId)
|
||||
} else if (method === 'wechat_pay') {
|
||||
await confirmWechatPay(stripe, clientSecret)
|
||||
} else {
|
||||
// Fallback: render full Payment Element
|
||||
showPaymentElement.value = true
|
||||
await nextTick()
|
||||
mountPaymentElement(stripe, clientSecret)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
})
|
||||
|
||||
async function confirmAlipay(stripe: Stripe, clientSecret: string, orderId: number) {
|
||||
redirecting.value = true
|
||||
const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success'
|
||||
const { error } = await stripe.confirmAlipayPayment(clientSecret, { return_url: returnUrl })
|
||||
if (error) {
|
||||
redirecting.value = false
|
||||
stripeError.value = error.message || t('payment.result.failed')
|
||||
}
|
||||
// If no error, Stripe redirects automatically — nothing else to do
|
||||
}
|
||||
|
||||
async function confirmWechatPay(stripe: Stripe, clientSecret: string) {
|
||||
const { paymentIntent, error } = await (stripe as Stripe & {
|
||||
confirmWechatPayPayment: (cs: string, opts: Record<string, unknown>) => Promise<{ paymentIntent?: { status: string; next_action?: { wechat_pay_display_qr_code?: { image_data_url?: string } } }; error?: { message?: string } }>
|
||||
}).confirmWechatPayPayment(clientSecret, {
|
||||
payment_method_options: { wechat_pay: { client: isMobileDevice() ? 'mobile_web' : 'web' } },
|
||||
})
|
||||
|
||||
if (error) {
|
||||
stripeError.value = error.message || t('payment.result.failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Extract QR code image from next_action
|
||||
const qrData = paymentIntent?.next_action?.wechat_pay_display_qr_code?.image_data_url
|
||||
if (qrData) {
|
||||
wechatQrUrl.value = qrData
|
||||
// Poll for completion
|
||||
startPolling()
|
||||
} else if (paymentIntent?.status === 'succeeded') {
|
||||
stripeSuccess.value = true
|
||||
scheduleClose()
|
||||
} else {
|
||||
stripeError.value = t('payment.result.failed')
|
||||
}
|
||||
}
|
||||
|
||||
function mountPaymentElement(stripe: Stripe, clientSecret: string) {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const elements = stripe.elements({
|
||||
clientSecret,
|
||||
appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } },
|
||||
})
|
||||
elementsInstance = elements
|
||||
const paymentElement = elements.create('payment', {
|
||||
layout: 'tabs',
|
||||
paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'],
|
||||
} as Record<string, unknown>)
|
||||
paymentElement.mount('#stripe-payment-element')
|
||||
paymentElement.on('ready', () => { stripeReady.value = true })
|
||||
}
|
||||
|
||||
async function handleGenericPay() {
|
||||
if (!stripeInstance || !elementsInstance || stripeSubmitting.value) return
|
||||
stripeSubmitting.value = true
|
||||
stripeError.value = ''
|
||||
try {
|
||||
const { error } = await stripeInstance.confirmPayment({
|
||||
elements: elementsInstance,
|
||||
confirmParams: {
|
||||
return_url: window.location.origin + '/payment/result?order_id=' + route.query.order_id + '&status=success',
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
if (error) {
|
||||
stripeError.value = error.message || t('payment.result.failed')
|
||||
} else {
|
||||
stripeSuccess.value = true
|
||||
scheduleClose()
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stripeError.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
} finally {
|
||||
stripeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startPolling() {
|
||||
const orderId = Number(route.query.order_id)
|
||||
if (!orderId) return
|
||||
pollTimer = setInterval(async () => {
|
||||
const o = await paymentStore.pollOrderStatus(orderId)
|
||||
if (!o) return
|
||||
if (o.status === 'COMPLETED' || o.status === 'PAID') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
stripeSuccess.value = true
|
||||
wechatQrUrl.value = ''
|
||||
scheduleClose()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function scheduleClose() {
|
||||
if (window.opener) {
|
||||
redirectTimer = setTimeout(() => { window.close() }, 2000)
|
||||
} else {
|
||||
redirectTimer = setTimeout(() => {
|
||||
router.push({ path: '/payment/result', query: { order_id: String(route.query.order_id || ''), status: 'success' } })
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
170
frontend/src/views/user/StripePopupView.vue
Normal file
170
frontend/src/views/user/StripePopupView.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-slate-50 p-4 dark:bg-slate-950">
|
||||
<div
|
||||
class="w-full max-w-md space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<!-- Amount + Order ID -->
|
||||
<div v-if="amount" class="text-center">
|
||||
<p class="text-3xl font-bold" :style="{ color: methodColor }">¥{{ amount }}</p>
|
||||
<p v-if="orderId" class="mt-1 text-sm text-gray-500 dark:text-slate-400">
|
||||
{{ t('payment.orders.orderId') }}: {{ orderId }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="space-y-3">
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<button
|
||||
class="w-full text-sm underline dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:style="{ color: methodColor }"
|
||||
@click="closeWindow"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div v-else-if="success" class="space-y-3 py-4 text-center">
|
||||
<div class="text-5xl text-green-600 dark:text-green-400">✓</div>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">{{ t('payment.result.success') }}</p>
|
||||
<button
|
||||
class="text-sm underline dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:style="{ color: methodColor }"
|
||||
@click="closeWindow"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading / Redirecting -->
|
||||
<div v-else class="flex items-center justify-center py-8">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
|
||||
:style="{ borderColor: methodColor, borderTopColor: 'transparent' }"
|
||||
/>
|
||||
<span class="ml-3 text-sm text-gray-500 dark:text-slate-400">{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
|
||||
interface StripeWithWechatPay {
|
||||
confirmWechatPayPayment(clientSecret: string, options: Record<string, unknown>): Promise<{ error?: { message?: string }; paymentIntent?: { status: string } }>
|
||||
}
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
alipay: '#00AEEF',
|
||||
wechat_pay: '#07C160',
|
||||
}
|
||||
const DEFAULT_METHOD_COLOR = '#635bff'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const orderId = String(route.query.order_id || '')
|
||||
const method = String(route.query.method || 'alipay')
|
||||
const amount = String(route.query.amount || '')
|
||||
|
||||
const methodColor = computed(() => METHOD_COLORS[method] || DEFAULT_METHOD_COLOR)
|
||||
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
const hint = ref(t('payment.stripePopup.redirecting'))
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function closeWindow() { window.close() }
|
||||
|
||||
onMounted(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (event.data?.type !== 'STRIPE_POPUP_INIT') return
|
||||
window.removeEventListener('message', handler)
|
||||
initStripe(event.data.clientSecret, event.data.publishableKey)
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!error.value && !success.value) {
|
||||
error.value = t('payment.stripePopup.timeout')
|
||||
}
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
|
||||
async function initStripe(clientSecret: string, publishableKey: string) {
|
||||
if (!clientSecret || !publishableKey) {
|
||||
error.value = t('payment.stripeMissingParams')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(publishableKey)
|
||||
if (!stripe) { error.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success'
|
||||
|
||||
if (method === 'alipay') {
|
||||
// Alipay: redirect this popup to Alipay payment page
|
||||
const { error: err } = await stripe.confirmAlipayPayment(clientSecret, { return_url: returnUrl })
|
||||
if (err) error.value = err.message || t('payment.result.failed')
|
||||
} else if (method === 'wechat_pay') {
|
||||
// WeChat: Stripe shows its built-in QR dialog, user scans, promise resolves
|
||||
hint.value = t('payment.stripePopup.loadingQr')
|
||||
const result = await (stripe as unknown as StripeWithWechatPay).confirmWechatPayPayment(clientSecret, {
|
||||
payment_method_options: { wechat_pay: { client: isMobileDevice() ? 'mobile_web' : 'web' } },
|
||||
})
|
||||
if (result.error) {
|
||||
error.value = result.error.message || t('payment.result.failed')
|
||||
} else if (result.paymentIntent?.status === 'succeeded') {
|
||||
success.value = true
|
||||
setTimeout(closeWindow, 2000)
|
||||
} else {
|
||||
// Payment not completed (user closed QR dialog)
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1]
|
||||
|| localStorage.getItem('token') || ''
|
||||
const res = await fetch('/api/v1/payment/orders/' + orderId, {
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const status = data?.data?.status
|
||||
if (status === 'COMPLETED' || status === 'PAID') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
success.value = true
|
||||
setTimeout(closeWindow, 2000)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
@@ -28,39 +28,50 @@
|
||||
<div
|
||||
v-for="subscription in subscriptions"
|
||||
:key="subscription.id"
|
||||
class="card overflow-hidden"
|
||||
class="overflow-hidden rounded-2xl border bg-white dark:bg-dark-800"
|
||||
:class="platformBorderClass(subscription.group?.platform || '')"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30"
|
||||
>
|
||||
<Icon name="creditCard" size="md" class="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div :class="['h-1.5 w-1.5 shrink-0 rounded-full', platformAccentDotClass(subscription.group?.platform || '')]" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ subscription.group?.description || '' }}
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
|
||||
</h3>
|
||||
<span :class="['rounded-md border px-2 py-0.5 text-[11px] font-medium', platformBadgeClass(subscription.group?.platform || '')]">
|
||||
{{ platformLabel(subscription.group?.platform || '') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="subscription.group?.description" class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ subscription.group.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
subscription.status === 'active'
|
||||
? 'badge-success'
|
||||
: subscription.status === 'expired'
|
||||
? 'badge-warning'
|
||||
: 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t(`userSubscriptions.status.${subscription.status}`) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
subscription.status === 'active'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
: subscription.status === 'expired'
|
||||
? 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
]"
|
||||
>
|
||||
{{ t(`userSubscriptions.status.${subscription.status}`) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="subscription.status === 'active'"
|
||||
:class="['rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-colors', platformButtonClass(subscription.group?.platform || '')]"
|
||||
@click="router.push({ path: '/purchase', query: { tab: 'subscription', group: String(subscription.group_id) } })"
|
||||
>
|
||||
{{ t('payment.renewNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Progress -->
|
||||
@@ -237,14 +248,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import subscriptionsAPI from '@/api/subscriptions'
|
||||
import type { UserSubscription } from '@/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
|
||||
|
||||
function platformAccentDotClass(p: string): string {
|
||||
switch (p) {
|
||||
case 'anthropic': return 'bg-orange-500'
|
||||
case 'openai': return 'bg-emerald-500'
|
||||
case 'antigravity': return 'bg-purple-500'
|
||||
case 'gemini': return 'bg-blue-500'
|
||||
default: return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const subscriptions = ref<UserSubscription[]>([])
|
||||
@@ -289,10 +313,10 @@ function formatExpirationDate(expiresAt: string): string {
|
||||
const dateStr = formatDateOnly(expires)
|
||||
|
||||
if (days === 0) {
|
||||
return `${dateStr} (Today)`
|
||||
return `${dateStr} (${t('common.today')})`
|
||||
}
|
||||
if (days === 1) {
|
||||
return `${dateStr} (Tomorrow)`
|
||||
return `${dateStr} (${t('common.tomorrow')})`
|
||||
}
|
||||
|
||||
return t('userSubscriptions.daysRemaining', { days }) + ` (${dateStr})`
|
||||
|
||||
@@ -149,7 +149,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-api_key="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{
|
||||
row.api_key?.name || '-'
|
||||
@@ -598,6 +606,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`
|
||||
@@ -660,6 +672,18 @@ const formatTokens = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
type UsageTableQueryParams = UsageQueryParams & {
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const buildUsageQueryParams = (page: number, pageSize: number): UsageTableQueryParams => ({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters.value,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
if (abortController) {
|
||||
@@ -670,13 +694,10 @@ const loadUsageLogs = async () => {
|
||||
const { signal } = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const params: UsageQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
...filters.value
|
||||
}
|
||||
|
||||
const response = await usageAPI.query(params, { signal })
|
||||
const response = await usageAPI.query(
|
||||
buildUsageQueryParams(pagination.page, pagination.page_size),
|
||||
{ signal }
|
||||
)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
@@ -758,6 +779,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape CSV value to prevent injection and handle special characters
|
||||
*/
|
||||
@@ -795,12 +823,7 @@ const exportToCSV = async () => {
|
||||
const totalRequests = Math.ceil(pagination.total / pageSize)
|
||||
|
||||
for (let page = 1; page <= totalRequests; page++) {
|
||||
const params: UsageQueryParams = {
|
||||
page: page,
|
||||
page_size: pageSize,
|
||||
...filters.value
|
||||
}
|
||||
const response = await usageAPI.query(params)
|
||||
const response = await usageAPI.query(buildUsageQueryParams(page, pageSize))
|
||||
allLogs.push(...response.items)
|
||||
}
|
||||
|
||||
|
||||
175
frontend/src/views/user/UserOrdersView.vue
Normal file
175
frontend/src/views/user/UserOrdersView.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Select v-model="currentFilter" :options="statusFilters" class="w-36" @change="fetchOrders" />
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<button @click="fetchOrders" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<OrderTable :orders="orders" :loading="loading">
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="row.status === 'PENDING'" @click="handleCancel(row.id)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20">
|
||||
<Icon name="x" size="sm" />
|
||||
<span>{{ t('payment.orders.cancel') }}</span>
|
||||
</button>
|
||||
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
||||
<Icon name="dollar" size="sm" />
|
||||
<span>{{ t('payment.orders.requestRefund') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</OrderTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Confirm Dialog -->
|
||||
<BaseDialog :show="!!cancelTargetId" :title="t('payment.orders.cancel')" width="narrow" @close="cancelTargetId = null">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">{{ t('payment.confirmCancel') }}</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" @click="cancelTargetId = null">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-danger" :disabled="actionLoading" @click="confirmCancel">{{ actionLoading ? t('common.processing') : t('payment.orders.cancel') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Refund Dialog -->
|
||||
<BaseDialog :show="!!refundTarget" :title="t('payment.orders.requestRefund')" @close="refundTarget = null">
|
||||
<div v-if="refundTarget" class="space-y-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">#{{ refundTarget.id }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ refundTarget.amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.refundReason') }}</label>
|
||||
<textarea v-model="refundReason" rows="3" class="input mt-1 w-full" :placeholder="t('payment.refundReasonPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" @click="refundTarget = null">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="actionLoading || !refundReason.trim()" @click="confirmRefund">{{ actionLoading ? t('common.processing') : t('payment.orders.requestRefund') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import OrderTable from '@/components/payment/OrderTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const orders = ref<PaymentOrder[]>([])
|
||||
const currentFilter = ref('')
|
||||
const cancelTargetId = ref<number | null>(null)
|
||||
const refundTarget = ref<PaymentOrder | null>(null)
|
||||
const refundReason = ref('')
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const statusFilters = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'PENDING', label: t('payment.status.pending') },
|
||||
{ value: 'COMPLETED', label: t('payment.status.completed') },
|
||||
{ value: 'FAILED', label: t('payment.status.failed') },
|
||||
{ value: 'REFUNDED', label: t('payment.status.refunded') },
|
||||
])
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await paymentAPI.getMyOrders({
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
status: currentFilter.value || undefined,
|
||||
})
|
||||
orders.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) { pagination.page = page; fetchOrders() }
|
||||
function handlePageSizeChange(size: number) { pagination.page_size = size; pagination.page = 1; fetchOrders() }
|
||||
|
||||
function handleCancel(orderId: number) { cancelTargetId.value = orderId }
|
||||
|
||||
async function confirmCancel() {
|
||||
if (!cancelTargetId.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(cancelTargetId.value)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
cancelTargetId.value = null
|
||||
await fetchOrders()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundDialog(order: PaymentOrder) { refundTarget.value = order; refundReason.value = '' }
|
||||
|
||||
async function confirmRefund() {
|
||||
if (!refundTarget.value || !refundReason.value.trim()) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await paymentAPI.requestRefund(refundTarget.value.id, { reason: refundReason.value.trim() })
|
||||
appStore.showSuccess(t('common.success'))
|
||||
refundTarget.value = null
|
||||
refundReason.value = ''
|
||||
await fetchOrders()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchOrders())
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user