Commit c520de11 authored by qingyuzhang's avatar qingyuzhang
Browse files

Merge branch 'main' of github.com:Wei-Shaw/sub2api into qingyu/fix-smooth-sidebar-collapse

# Conflicts:
#	frontend/src/components/layout/AppSidebar.vue
parents 07d2add6 97f14b7a
...@@ -26,6 +26,7 @@ import scheduledTestsAPI from './scheduledTests' ...@@ -26,6 +26,7 @@ import scheduledTestsAPI from './scheduledTests'
import backupAPI from './backup' import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile' import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels' import channelsAPI from './channels'
import adminPaymentAPI from './payment'
/** /**
* Unified admin API object for convenient access * Unified admin API object for convenient access
...@@ -53,7 +54,8 @@ export const adminAPI = { ...@@ -53,7 +54,8 @@ export const adminAPI = {
scheduledTests: scheduledTestsAPI, scheduledTests: scheduledTestsAPI,
backup: backupAPI, backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI, tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI channels: channelsAPI,
payment: adminPaymentAPI
} }
export { export {
...@@ -79,7 +81,8 @@ export { ...@@ -79,7 +81,8 @@ export {
scheduledTestsAPI, scheduledTestsAPI,
backupAPI, backupAPI,
tlsFingerprintProfileAPI, tlsFingerprintProfileAPI,
channelsAPI channelsAPI,
adminPaymentAPI
} }
export default adminAPI export default adminAPI
......
/**
* 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( ...@@ -17,10 +17,16 @@ export async function list(
filters?: { filters?: {
status?: string status?: string
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
} }
): Promise<BasePaginationResponse<PromoCode>> { ): Promise<BasePaginationResponse<PromoCode>> {
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', { 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 return data
} }
......
...@@ -29,6 +29,8 @@ export async function list( ...@@ -29,6 +29,8 @@ export async function list(
protocol?: string protocol?: string
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
...@@ -227,16 +229,20 @@ export async function exportData(options?: { ...@@ -227,16 +229,20 @@ export async function exportData(options?: {
protocol?: string protocol?: string
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
}): Promise<AdminDataPayload> { }): Promise<AdminDataPayload> {
const params: Record<string, string> = {} const params: Record<string, string> = {}
if (options?.ids && options.ids.length > 0) { if (options?.ids && options.ids.length > 0) {
params.ids = options.ids.join(',') params.ids = options.ids.join(',')
} else if (options?.filters) { } 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 (protocol) params.protocol = protocol
if (status) params.status = status if (status) params.status = status
if (search) params.search = search 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 }) const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
return data return data
......
...@@ -25,6 +25,8 @@ export async function list( ...@@ -25,6 +25,8 @@ export async function list(
type?: RedeemCodeType type?: RedeemCodeType
status?: 'active' | 'used' | 'expired' | 'unused' status?: 'active' | 'used' | 'expired' | 'unused'
search?: string search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
...@@ -151,7 +153,10 @@ export async function getStats(): Promise<{ ...@@ -151,7 +153,10 @@ export async function getStats(): Promise<{
*/ */
export async function exportCodes(filters?: { export async function exportCodes(filters?: {
type?: RedeemCodeType type?: RedeemCodeType
status?: 'active' | 'used' | 'expired' status?: 'used' | 'expired' | 'unused'
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}): Promise<Blob> { }): Promise<Blob> {
const response = await apiClient.get('/admin/redeem-codes/export', { const response = await apiClient.get('/admin/redeem-codes/export', {
params: filters, params: filters,
......
...@@ -38,8 +38,8 @@ export interface SystemSettings { ...@@ -38,8 +38,8 @@ export interface SystemSettings {
doc_url: string doc_url: string
home_content: string home_content: string
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean table_default_page_size: number
purchase_subscription_url: string table_page_size_options: number[]
backend_mode_enabled: boolean backend_mode_enabled: boolean
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
...@@ -114,6 +114,26 @@ export interface SystemSettings { ...@@ -114,6 +114,26 @@ export interface SystemSettings {
enable_fingerprint_unification: boolean enable_fingerprint_unification: boolean
enable_metadata_passthrough: boolean enable_metadata_passthrough: boolean
enable_cch_signing: 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 { export interface UpdateSettingsRequest {
...@@ -136,8 +156,8 @@ export interface UpdateSettingsRequest { ...@@ -136,8 +156,8 @@ export interface UpdateSettingsRequest {
doc_url?: string doc_url?: string
home_content?: string home_content?: string
hide_ccs_import_button?: boolean hide_ccs_import_button?: boolean
purchase_subscription_enabled?: boolean table_default_page_size?: number
purchase_subscription_url?: string table_page_size_options?: number[]
backend_mode_enabled?: boolean backend_mode_enabled?: boolean
custom_menu_items?: CustomMenuItem[] custom_menu_items?: CustomMenuItem[]
custom_endpoints?: CustomEndpoint[] custom_endpoints?: CustomEndpoint[]
...@@ -194,6 +214,25 @@ export interface UpdateSettingsRequest { ...@@ -194,6 +214,25 @@ export interface UpdateSettingsRequest {
enable_fingerprint_unification?: boolean enable_fingerprint_unification?: boolean
enable_metadata_passthrough?: boolean enable_metadata_passthrough?: boolean
enable_cch_signing?: 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 { ...@@ -81,6 +81,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number user_id?: number
exact_total?: boolean exact_total?: boolean
billing_mode?: string billing_mode?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
// ==================== API Functions ==================== // ==================== API Functions ====================
......
...@@ -24,6 +24,8 @@ export async function list( ...@@ -24,6 +24,8 @@ export async function list(
group_name?: string // fuzzy filter by allowed group name group_name?: string // fuzzy filter by allowed group name
attributes?: Record<number, string> // attributeId -> value attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean include_subscriptions?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
...@@ -37,7 +39,9 @@ export async function list( ...@@ -37,7 +39,9 @@ export async function list(
role: filters?.role, role: filters?.role,
search: filters?.search, search: filters?.search,
group_name: filters?.group_name, 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 // Add attribute filters as attr[id]=value
......
...@@ -92,10 +92,13 @@ apiClient.interceptors.response.use( ...@@ -92,10 +92,13 @@ apiClient.interceptors.response.use(
response.data = apiResponse.data response.data = apiResponse.data
} else { } else {
// API error // API error
const resp = apiResponse as Record<string, unknown>
return Promise.reject({ return Promise.reject({
status: response.status, status: response.status,
code: apiResponse.code, 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( ...@@ -268,7 +271,9 @@ apiClient.interceptors.response.use(
status, status,
code: apiData.code, code: apiData.code,
error: apiData.error, 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' ...@@ -14,6 +14,7 @@ export { keysAPI } from './keys'
export { usageAPI } from './usage' export { usageAPI } from './usage'
export { userAPI } from './user' export { userAPI } from './user'
export { redeemAPI, type RedeemHistoryItem } from './redeem' export { redeemAPI, type RedeemHistoryItem } from './redeem'
export { paymentAPI } from './payment'
export { userGroupsAPI } from './groups' export { userGroupsAPI } from './groups'
export { totpAPI } from './totp' export { totpAPI } from './totp'
export { default as announcementsAPI } from './announcements' export { default as announcementsAPI } from './announcements'
......
...@@ -17,7 +17,13 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons ...@@ -17,7 +17,13 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons
export async function list( export async function list(
page: number = 1, page: number = 1,
pageSize: number = 10, 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?: { options?: {
signal?: AbortSignal signal?: AbortSignal
} }
......
/**
* 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( ...@@ -91,7 +91,7 @@ export async function list(
* @returns Paginated list of usage logs * @returns Paginated list of usage logs
*/ */
export async function query( export async function query(
params: UsageQueryParams, params: UsageQueryParams & { sort_by?: string; sort_order?: 'asc' | 'desc' },
config: { signal?: AbortSignal } = {} config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UsageLog>> { ): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', { const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
......
<?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>
\ No newline at end of file
<?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>
\ No newline at end of file
<?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>
\ No newline at end of file
<?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>
\ No newline at end of file
...@@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u ...@@ -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 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 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 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(() => [ const privacyOpts = computed(() => [
{ value: '', label: t('admin.accounts.allPrivacyModes') }, { value: '', label: t('admin.accounts.allPrivacyModes') },
{ value: '__unset__', label: t('admin.accounts.privacyUnset') }, { value: '__unset__', label: t('admin.accounts.privacyUnset') },
......
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 @@ ...@@ -21,7 +21,15 @@
</button> </button>
</div> </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 }"> <template #cell-email="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
...@@ -62,7 +70,7 @@ ...@@ -62,7 +70,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
...@@ -98,23 +106,54 @@ const pagination = reactive({ ...@@ -98,23 +106,54 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'email',
sort_order: 'asc' as 'asc' | 'desc'
})
const items = ref<AnnouncementUserReadStatus[]>([]) const items = ref<AnnouncementUserReadStatus[]>([])
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'email', label: t('common.email') }, { key: 'email', label: t('common.email'), sortable: true },
{ key: 'username', label: t('admin.users.columns.username') }, { key: 'username', label: t('admin.users.columns.username'), sortable: true },
{ key: 'balance', label: t('common.balance') }, { key: 'balance', label: t('common.balance'), sortable: true },
{ key: 'eligible', label: t('admin.announcements.eligible') }, { key: 'eligible', label: t('admin.announcements.eligible') },
{ key: 'read_at', label: t('admin.announcements.readAt') } { key: 'read_at', label: t('admin.announcements.readAt') }
]) ])
let currentController: AbortController | null = null 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() { async function load() {
if (!props.show || !props.announcementId) return if (!props.show || !props.announcementId) return
if (currentController) currentController.abort() currentController?.abort()
currentController = new AbortController() const requestController = new AbortController()
currentController = requestController
const { signal } = requestController
try { try {
loading.value = true loading.value = true
...@@ -122,20 +161,37 @@ async function load() { ...@@ -122,20 +161,37 @@ async function load() {
props.announcementId, props.announcementId,
pagination.page, pagination.page,
pagination.page_size, 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 items.value = res.items
pagination.total = res.total pagination.total = res.total
pagination.pages = res.pages pagination.pages = res.pages
pagination.page = res.page pagination.page = res.page
pagination.page_size = res.page_size pagination.page_size = res.page_size
} catch (error: any) { } 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) console.error('Failed to load read status:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus')) appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
} finally { } finally {
loading.value = false if (currentController === requestController) {
loading.value = false
currentController = null
}
} }
} }
...@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) { ...@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
load() 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() { function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer) if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => { searchDebounceTimer = window.setTimeout(() => {
...@@ -160,13 +222,17 @@ function handleSearch() { ...@@ -160,13 +222,17 @@ function handleSearch() {
} }
function handleClose() { function handleClose() {
cancelPendingLoad(true)
emit('close') emit('close')
} }
watch( watch(
() => props.show, () => props.show,
(v) => { (v) => {
if (!v) return if (!v) {
cancelPendingLoad(true)
return
}
pagination.page = 1 pagination.page = 1
load() load()
} }
...@@ -181,7 +247,7 @@ watch( ...@@ -181,7 +247,7 @@ watch(
} }
) )
onMounted(() => { onUnmounted(() => {
// noop cancelPendingLoad()
}) })
</script> </script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment