"frontend/vscode:/vscode.git/clone" did not exist on "d67ecf893d9c961afad592e65b2f9b28861cea80"
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
...@@ -16,20 +16,22 @@ ...@@ -16,20 +16,22 @@
@apply min-h-screen; @apply min-h-screen;
} }
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */ /* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */
* { @supports (-moz-appearance:none) {
scrollbar-width: thin; * {
scrollbar-color: transparent transparent; scrollbar-width: thin;
} scrollbar-color: transparent transparent;
}
*:hover, *:hover,
*:focus-within { *:focus-within {
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
} }
.dark *:hover, .dark *:hover,
.dark *:focus-within { .dark *:focus-within {
scrollbar-color: rgba(75, 85, 99, 0.5) transparent; scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
}
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
...@@ -58,36 +60,7 @@ ...@@ -58,36 +60,7 @@
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100; @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 { @layer components {
...@@ -141,6 +114,27 @@ ...@@ -141,6 +114,27 @@
@apply dark:shadow-amber-500/20; @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 { .btn-sm {
@apply rounded-lg px-3 py-1.5 text-xs; @apply rounded-lg px-3 py-1.5 text-xs;
} }
......
...@@ -104,14 +104,14 @@ export interface PublicSettings { ...@@ -104,14 +104,14 @@ export interface PublicSettings {
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 payment_enabled: boolean
purchase_subscription_url: string table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
oidc_oauth_enabled: boolean oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string oidc_oauth_provider_name: string
sora_client_enabled: boolean
backend_mode_enabled: boolean backend_mode_enabled: boolean
version: string version: string
} }
...@@ -1363,6 +1363,8 @@ export interface UsageQueryParams { ...@@ -1363,6 +1363,8 @@ export interface UsageQueryParams {
billing_type?: number | null billing_type?: number | null
start_date?: string start_date?: string
end_date?: string end_date?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
// ==================== Account Usage Statistics ==================== // ==================== Account Usage Statistics ====================
...@@ -1629,3 +1631,6 @@ export interface UpdateScheduledTestPlanRequest { ...@@ -1629,3 +1631,6 @@ export interface UpdateScheduledTestPlanRequest {
max_results?: number max_results?: number
auto_recover?: boolean auto_recover?: boolean
} }
// Payment types
export type { SubscriptionPlan, PaymentOrder, CheckoutInfoResponse } from './payment'
/**
* 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 }[]
}
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])
})
})
/**
* 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
}
/**
* 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)
}
/**
* 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'
}
}
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 @@ ...@@ -148,6 +148,8 @@
:data="accounts" :data="accounts"
:loading="loading" :loading="loading"
row-key="id" row-key="id"
:server-side-sort="true"
@sort="handleSort"
default-sort-key="name" default-sort-key="name"
default-sort-order="asc" default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' ...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings // Sorting settings
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort' 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 // Auto refresh settings
const showAutoRefreshDropdown = ref(false) const showAutoRefreshDropdown = ref(false)
...@@ -594,7 +627,16 @@ const { ...@@ -594,7 +627,16 @@ const {
handlePageSizeChange: baseHandlePageSizeChange handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({ } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, 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 { const {
...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => { ...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
baseHandlePageSizeChange(size) 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) => { watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) { if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false pendingTodayStatsRefresh.value = false
...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => { ...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
privacy_mode?: string privacy_mode?: string
group?: string group?: string
search?: string search?: string
sort_by?: string
sort_order?: AccountSortOrder
}, },
{ etag: autoRefreshETag.value } { etag: autoRefreshETag.value }
...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => { ...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
} }
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() } const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
const handleDataImported = () => { showImportData.value = false; 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) => { const accountMatchesCurrentFilters = (account: Account) => {
if (params.platform && account.platform !== params.platform) return false const filters = buildAccountQueryFilters()
if (params.type && account.type !== params.type) return false if (filters.platform && account.platform !== filters.platform) return false
if (params.status) { if (filters.type && account.type !== filters.type) return false
if (params.status === 'rate_limited') { if (filters.status) {
if (!account.rate_limit_reset_at) return false const now = Date.now()
const resetAt = new Date(account.rate_limit_reset_at).getTime() const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
} else if (account.status !== params.status) { 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
}
}
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 return false
} }
} }
const search = String(params.search || '').trim().toLowerCase() const search = String(filters.search || '').trim().toLowerCase()
if (search && !account.name.toLowerCase().includes(search)) return false if (search && !account.name.toLowerCase().includes(search)) return false
return true return true
} }
...@@ -1181,12 +1277,7 @@ const handleExportData = async () => { ...@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
? { ids: selIds.value, includeProxies: includeProxyOnExport.value } ? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: { : {
includeProxies: includeProxyOnExport.value, includeProxies: includeProxyOnExport.value,
filters: { filters: buildAccountQueryFilters()
platform: params.platform,
type: params.type,
status: params.status,
search: params.search
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()
......
...@@ -39,7 +39,15 @@ ...@@ -39,7 +39,15 @@
</template> </template>
<template #table> <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 }"> <template #cell-title="{ value, row }">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
...@@ -68,7 +76,7 @@ ...@@ -68,7 +76,7 @@
</span> </span>
</template> </template>
<template #cell-notifyMode="{ row }"> <template #cell-notify_mode="{ row }">
<span <span
:class="[ :class="[
'badge', 'badge',
...@@ -100,7 +108,7 @@ ...@@ -100,7 +108,7 @@
</div> </div>
</template> </template>
<template #cell-createdAt="{ value }"> <template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
...@@ -236,7 +244,7 @@ ...@@ -236,7 +244,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize' import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
...@@ -276,6 +284,11 @@ const pagination = reactive({ ...@@ -276,6 +284,11 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const statusFilterOptions = computed(() => [ const statusFilterOptions = computed(() => [
{ value: '', label: t('admin.announcements.allStatus') }, { value: '', label: t('admin.announcements.allStatus') },
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') }, { value: 'draft', label: t('admin.announcements.statusLabels.draft') },
...@@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [ ...@@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [
]) ])
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'title', label: t('admin.announcements.columns.title') }, { key: 'title', label: t('admin.announcements.columns.title'), sortable: true },
{ key: 'status', label: t('admin.announcements.columns.status') }, { key: 'status', label: t('admin.announcements.columns.status'), sortable: true },
{ key: 'notifyMode', label: t('admin.announcements.columns.notifyMode') }, { key: 'notify_mode', label: t('admin.announcements.columns.notifyMode'), sortable: true },
{ key: 'targeting', label: t('admin.announcements.columns.targeting') }, { key: 'targeting', label: t('admin.announcements.columns.targeting') },
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') }, { 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') } { key: 'actions', label: t('admin.announcements.columns.actions') }
]) ])
...@@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => { ...@@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => {
let currentController: AbortController | null = null let currentController: AbortController | null = null
async function loadAnnouncements() { async function loadAnnouncements() {
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
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, { const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
status: filters.status || undefined, 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 announcements.value = res.items
pagination.total = res.total pagination.total = res.total
...@@ -337,11 +356,21 @@ async function loadAnnouncements() { ...@@ -337,11 +356,21 @@ async function loadAnnouncements() {
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('Error loading announcements:', error) console.error('Error loading announcements:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad')) appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
} finally { } finally {
loading.value = false if (currentController === requestController) {
loading.value = false
currentController = null
}
} }
} }
...@@ -361,6 +390,13 @@ function handleStatusChange() { ...@@ -361,6 +390,13 @@ function handleStatusChange() {
loadAnnouncements() 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 let searchDebounceTimer: number | null = null
function handleSearch() { function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer) if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
...@@ -562,4 +598,9 @@ onMounted(async () => { ...@@ -562,4 +598,9 @@ onMounted(async () => {
await loadSubscriptionGroups() await loadSubscriptionGroups()
await loadAnnouncements() await loadAnnouncements()
}) })
onUnmounted(() => {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
currentController?.abort()
})
</script> </script>
...@@ -48,7 +48,15 @@ ...@@ -48,7 +48,15 @@
</template> </template>
<template #table> <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 }"> <template #cell-name="{ 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>
...@@ -486,6 +494,10 @@ const pagination = reactive({ ...@@ -486,6 +494,10 @@ const pagination = reactive({
page_size: getPersistedPageSize(), page_size: getPersistedPageSize(),
total: 0 total: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Dialog state // Dialog state
const showDialog = ref(false) const showDialog = ref(false)
...@@ -766,7 +778,9 @@ async function loadChannels() { ...@@ -766,7 +778,9 @@ async function loadChannels() {
try { try {
const response = await adminAPI.channels.list(pagination.page, pagination.page_size, { const response = await adminAPI.channels.list(pagination.page, pagination.page_size, {
status: filters.status || undefined, 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 }) }, { signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return if (ctrl.signal.aborted || abortController !== ctrl) return
...@@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) { ...@@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) {
loadChannels() loadChannels()
} }
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadChannels()
}
// ── Dialog ── // ── Dialog ──
function resetForm() { function resetForm() {
form.name = '' form.name = ''
......
...@@ -81,7 +81,15 @@ ...@@ -81,7 +81,15 @@
</template> </template>
<template #table> <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 }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ <span class="font-medium text-gray-900 dark:text-white">{{
value value
...@@ -2924,6 +2932,10 @@ const pagination = reactive({ ...@@ -2924,6 +2932,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0, pages: 0,
}); });
const sortState = reactive({
sort_by: "sort_order",
sort_order: "asc" as "asc" | "desc",
});
let abortController: AbortController | null = null; let abortController: AbortController | null = null;
...@@ -3290,6 +3302,8 @@ const loadGroups = async () => { ...@@ -3290,6 +3302,8 @@ const loadGroups = async () => {
? filters.is_exclusive === "true" ? filters.is_exclusive === "true"
: undefined, : undefined,
search: searchQuery.value.trim() || undefined, search: searchQuery.value.trim() || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order,
}, },
{ signal }, { signal },
); );
...@@ -3392,6 +3406,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -3392,6 +3406,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadGroups(); loadGroups();
}; };
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key;
sortState.sort_order = order;
pagination.page = 1;
loadGroups();
};
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false; showCreateModal.value = false;
createModelRoutingRules.value.forEach((rule) => { createModelRoutingRules.value.forEach((rule) => {
......
...@@ -39,7 +39,15 @@ ...@@ -39,7 +39,15 @@
</template> </template>
<template #table> <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 }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code> <code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
...@@ -349,7 +357,6 @@ ...@@ -349,7 +357,6 @@
:page="usagesPage" :page="usagesPage"
:total="usagesTotal" :total="usagesTotal"
:page-size="usagesPageSize" :page-size="usagesPageSize"
:page-size-options="[10, 20, 50]"
@update:page="handleUsagesPageChange" @update:page="handleUsagesPageChange"
@update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }" @update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
/> />
...@@ -418,6 +425,10 @@ const pagination = reactive({ ...@@ -418,6 +425,10 @@ const pagination = reactive({
page_size: getPersistedPageSize(), page_size: getPersistedPageSize(),
total: 0 total: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Dialogs // Dialogs
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
...@@ -514,19 +525,29 @@ const loadCodes = async () => { ...@@ -514,19 +525,29 @@ const loadCodes = async () => {
pagination.page_size, pagination.page_size,
{ {
status: filters.status || undefined, 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 codes.value = response.items
pagination.total = response.total pagination.total = response.total
} catch (error: any) { } 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')) appStore.showError(t('admin.promo.failedToLoad'))
console.error('Error loading promo codes:', error) console.error('Error loading promo codes:', error)
} finally { } finally {
if (abortController === currentController && !currentController.signal.aborted) { if (abortController === currentController) {
loading.value = false loading.value = false
abortController = null abortController = null
} }
...@@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes() 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 copyToClipboard = async (text: string) => {
const success = await clipboardCopy(text, t('admin.promo.copied')) const success = await clipboardCopy(text, t('admin.promo.copied'))
if (success) { if (success) {
......
...@@ -89,7 +89,15 @@ ...@@ -89,7 +89,15 @@
<template #table> <template #table>
<div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden"> <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> <template #header-select>
<input <input
type="checkbox" type="checkbox"
...@@ -946,6 +954,10 @@ const pagination = reactive({ ...@@ -946,6 +954,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'id',
sort_order: 'desc' as 'asc' | 'desc'
})
const showCreateModal = ref(false) const showCreateModal = ref(false)
const createPasswordVisible = ref(false) const createPasswordVisible = ref(false)
...@@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => { ...@@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => {
toggleVisible(target.checked) 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 () => { const loadProxies = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
...@@ -1058,11 +1078,12 @@ const loadProxies = async () => { ...@@ -1058,11 +1078,12 @@ const loadProxies = async () => {
abortController = currentAbortController abortController = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, { const response = await adminAPI.proxies.list(
protocol: filters.protocol || undefined, pagination.page,
status: filters.status as any, pagination.page_size,
search: searchQuery.value || undefined buildProxyQueryFilters(),
}, { signal: currentAbortController.signal }) { signal: currentAbortController.signal }
)
if (currentAbortController.signal.aborted || abortController !== currentAbortController) { if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
return return
} }
...@@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadProxies() loadProxies()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadProxies()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createMode.value = 'standard' createMode.value = 'standard'
...@@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => { ...@@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
{ {
protocol: filters.protocol || undefined, protocol: filters.protocol || undefined,
status: filters.status as any, 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) result.push(...response.items)
...@@ -1689,11 +1719,7 @@ const handleExportData = async () => { ...@@ -1689,11 +1719,7 @@ const handleExportData = async () => {
selectedCount.value > 0 selectedCount.value > 0
? { ids: Array.from(selectedProxyIds.value) } ? { ids: Array.from(selectedProxyIds.value) }
: { : {
filters: { filters: buildProxyQueryFilters()
protocol: filters.protocol || undefined,
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
search: searchQuery.value || undefined
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()
......
...@@ -47,7 +47,15 @@ ...@@ -47,7 +47,15 @@
</template> </template>
<template #table> <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 }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code> <code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
...@@ -537,6 +545,10 @@ const pagination = reactive({ ...@@ -537,6 +545,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'id',
sort_order: 'desc' as 'asc' | 'desc'
})
let abortController: AbortController | null = null let abortController: AbortController | null = null
...@@ -565,6 +577,14 @@ watch( ...@@ -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 () => { const loadCodes = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
...@@ -576,11 +596,7 @@ const loadCodes = async () => { ...@@ -576,11 +596,7 @@ const loadCodes = async () => {
const response = await adminAPI.redeem.list( const response = await adminAPI.redeem.list(
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
{ buildRedeemQueryFilters(),
type: filters.type as RedeemCodeType,
status: filters.status as any,
search: searchQuery.value || undefined
},
{ {
signal: currentController.signal signal: currentController.signal
} }
...@@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes() loadCodes()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadCodes()
}
const handleGenerateCodes = async () => { const handleGenerateCodes = async () => {
// 订阅类型必须选择分组 // 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) { if (generateForm.type === 'subscription' && !generateForm.group_id) {
...@@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => { ...@@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => {
const handleExportCodes = async () => { const handleExportCodes = async () => {
try { try {
const blob = await adminAPI.redeem.exportCodes({ const blob = await adminAPI.redeem.exportCodes(buildRedeemQueryFilters())
type: filters.type as RedeemCodeType,
status: filters.status as any
})
// Create download link // Create download link
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
......
...@@ -1788,6 +1788,48 @@ ...@@ -1788,6 +1788,48 @@
</p> </p>
</div> </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 --> <!-- Custom Endpoints -->
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
...@@ -1947,70 +1989,6 @@ ...@@ -1947,70 +1989,6 @@
</div> </div>
</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 --> <!-- Custom Menu Items -->
<div class="card"> <div class="card">
...@@ -2136,6 +2114,124 @@ ...@@ -2136,6 +2114,124 @@
</div><!-- /Tab: General --> </div><!-- /Tab: General -->
<!-- Tab: Email --> <!-- 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"> <div v-show="activeTab === 'email'" class="space-y-6">
<!-- Email disabled hint - show when email_verify_enabled is off --> <!-- Email disabled hint - show when email_verify_enabled is off -->
<div v-if="!form.email_verify_enabled" class="card"> <div v-if="!form.email_verify_enabled" class="card">
...@@ -2388,6 +2484,21 @@ ...@@ -2388,6 +2484,21 @@
</button> </button>
</div> </div>
</form> </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> </div>
</AppLayout> </AppLayout>
</template> </template>
...@@ -2402,15 +2513,20 @@ import type { ...@@ -2402,15 +2513,20 @@ import type {
DefaultSubscriptionSetting DefaultSubscriptionSetting
} from '@/api/admin/settings' } from '@/api/admin/settings'
import type { AdminGroup } from '@/types' import type { AdminGroup } from '@/types'
import type { ProviderInstance } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import Select from '@/components/common/Select.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 GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue' import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
import Toggle from '@/components/common/Toggle.vue' import Toggle from '@/components/common/Toggle.vue'
import ImageUpload from '@/components/common/ImageUpload.vue' import ImageUpload from '@/components/common/ImageUpload.vue'
import BackupSettings from '@/views/admin/BackupView.vue' import BackupSettings from '@/views/admin/BackupView.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { extractApiErrorMessage } from '@/utils/apiError'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { useAdminSettingsStore } from '@/stores/adminSettings' import { useAdminSettingsStore } from '@/stores/adminSettings'
import { import {
...@@ -2424,13 +2540,14 @@ const { t } = useI18n() ...@@ -2424,13 +2540,14 @@ const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const adminSettingsStore = useAdminSettingsStore() 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 activeTab = ref<SettingsTab>('general')
const settingsTabs = [ const settingsTabs = [
{ key: 'general' as SettingsTab, icon: 'home' as const }, { key: 'general' as SettingsTab, icon: 'home' as const },
{ key: 'security' as SettingsTab, icon: 'shield' as const }, { key: 'security' as SettingsTab, icon: 'shield' as const },
{ key: 'users' as SettingsTab, icon: 'user' as const }, { key: 'users' as SettingsTab, icon: 'user' as const },
{ key: 'gateway' as SettingsTab, icon: 'server' 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: 'email' as SettingsTab, icon: 'mail' as const },
{ key: 'backup' as SettingsTab, icon: 'database' as const }, { key: 'backup' as SettingsTab, icon: 'database' as const },
] ]
...@@ -2445,6 +2562,7 @@ const smtpPasswordManuallyEdited = ref(false) ...@@ -2445,6 +2562,7 @@ const smtpPasswordManuallyEdited = ref(false)
const testEmailAddress = ref('') const testEmailAddress = ref('')
const registrationEmailSuffixWhitelistTags = ref<string[]>([]) const registrationEmailSuffixWhitelistTags = ref<string[]>([])
const registrationEmailSuffixWhitelistDraft = ref('') const registrationEmailSuffixWhitelistDraft = ref('')
const tablePageSizeOptionsInput = ref('10, 20, 50, 100')
// Admin API Key 状态 // Admin API Key 状态
const adminApiKeyLoading = ref(true) const adminApiKeyLoading = ref(true)
...@@ -2499,6 +2617,10 @@ const betaPolicyForm = reactive({ ...@@ -2499,6 +2617,10 @@ const betaPolicyForm = reactive({
}> }>
}) })
const tablePageSizeMin = 5
const tablePageSizeMax = 1000
const tablePageSizeDefault = 20
interface DefaultSubscriptionGroupOption { interface DefaultSubscriptionGroupOption {
value: number value: number
label: string label: string
...@@ -2537,8 +2659,9 @@ const form = reactive<SettingsForm>({ ...@@ -2537,8 +2659,9 @@ const form = reactive<SettingsForm>({
home_content: '', home_content: '',
backend_mode_enabled: false, backend_mode_enabled: false,
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false, 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',
purchase_subscription_url: '', 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_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}>, custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
frontend_url: '', frontend_url: '',
...@@ -2762,12 +2885,47 @@ function removeEndpoint(index: number) { ...@@ -2762,12 +2885,47 @@ function removeEndpoint(index: number) {
form.custom_endpoints.splice(index, 1) 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() { async function loadSettings() {
loading.value = true loading.value = true
loadFailed.value = false loadFailed.value = false
try { try {
const settings = await adminAPI.settings.getSettings() 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.backend_mode_enabled = settings.backend_mode_enabled
form.default_subscriptions = Array.isArray(settings.default_subscriptions) form.default_subscriptions = Array.isArray(settings.default_subscriptions)
? settings.default_subscriptions ? settings.default_subscriptions
...@@ -2780,17 +2938,18 @@ async function loadSettings() { ...@@ -2780,17 +2938,18 @@ async function loadSettings() {
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist 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 = '' registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = '' form.smtp_password = ''
smtpPasswordManuallyEdited.value = false smtpPasswordManuallyEdited.value = false
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = '' form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = '' form.oidc_connect_client_secret = ''
} catch (error: any) { } catch (error: unknown) {
loadFailed.value = true loadFailed.value = true
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad')))
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
loading.value = false loading.value = false
} }
...@@ -2802,8 +2961,7 @@ async function loadSubscriptionGroups() { ...@@ -2802,8 +2961,7 @@ async function loadSubscriptionGroups() {
subscriptionGroups.value = groups.filter( subscriptionGroups.value = groups.filter(
(group) => group.subscription_type === 'subscription' && group.status === 'active' (group) => group.subscription_type === 'subscription' && group.status === 'active'
) )
} catch (error) { } catch (_error: unknown) {
console.error('Failed to load subscription groups:', error)
subscriptionGroups.value = [] subscriptionGroups.value = []
} }
} }
...@@ -2826,6 +2984,37 @@ function removeDefaultSubscription(index: number) { ...@@ -2826,6 +2984,37 @@ function removeDefaultSubscription(index: number) {
async function saveSettings() { async function saveSettings() {
saving.value = true saving.value = true
try { 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 const normalizedDefaultSubscriptions = form.default_subscriptions
.filter((item) => item.group_id > 0 && item.validity_days > 0) .filter((item) => item.group_id > 0 && item.validity_days > 0)
.map((item: DefaultSubscriptionSetting) => ({ .map((item: DefaultSubscriptionSetting) => ({
...@@ -2863,21 +3052,6 @@ async function saveSettings() { ...@@ -2863,21 +3052,6 @@ async function saveSettings() {
// Optional URL fields: auto-clear invalid values so they don't cause backend 400 errors // 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.frontend_url)) form.frontend_url = ''
if (!isValidHttpUrl(form.doc_url)) form.doc_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 = { const payload: UpdateSettingsRequest = {
registration_enabled: form.registration_enabled, registration_enabled: form.registration_enabled,
...@@ -2901,8 +3075,8 @@ async function saveSettings() { ...@@ -2901,8 +3075,8 @@ async function saveSettings() {
home_content: form.home_content, home_content: form.home_content,
backend_mode_enabled: form.backend_mode_enabled, backend_mode_enabled: form.backend_mode_enabled,
hide_ccs_import_button: form.hide_ccs_import_button, hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled, table_default_page_size: form.table_default_page_size,
purchase_subscription_url: form.purchase_subscription_url, table_page_size_options: form.table_page_size_options,
custom_menu_items: form.custom_menu_items, custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints, custom_endpoints: form.custom_endpoints,
frontend_url: form.frontend_url, frontend_url: form.frontend_url,
...@@ -2954,13 +3128,40 @@ async function saveSettings() { ...@@ -2954,13 +3128,40 @@ async function saveSettings() {
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling, allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling,
enable_fingerprint_unification: form.enable_fingerprint_unification, enable_fingerprint_unification: form.enable_fingerprint_unification,
enable_metadata_passthrough: form.enable_metadata_passthrough, 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) 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( registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist 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 = '' registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = '' form.smtp_password = ''
smtpPasswordManuallyEdited.value = false smtpPasswordManuallyEdited.value = false
...@@ -2971,10 +3172,8 @@ async function saveSettings() { ...@@ -2971,10 +3172,8 @@ async function saveSettings() {
await appStore.fetchPublicSettings(true) await appStore.fetchPublicSettings(true)
await adminSettingsStore.fetch(true) await adminSettingsStore.fetch(true)
appStore.showSuccess(t('admin.settings.settingsSaved')) appStore.showSuccess(t('admin.settings.settingsSaved'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave')))
t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
saving.value = false saving.value = false
} }
...@@ -2993,10 +3192,8 @@ async function testSmtpConnection() { ...@@ -2993,10 +3192,8 @@ async function testSmtpConnection() {
}) })
// API returns { message: "..." } on success, errors are thrown as exceptions // API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess')) appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToTestSmtp')))
t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
testingSmtp.value = false testingSmtp.value = false
} }
...@@ -3023,10 +3220,8 @@ async function sendTestEmail() { ...@@ -3023,10 +3220,8 @@ async function sendTestEmail() {
}) })
// API returns { message: "..." } on success, errors are thrown as exceptions // API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.testEmailSent')) appStore.showSuccess(result.message || t('admin.settings.testEmailSent'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSendTestEmail')))
t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
sendingTestEmail.value = false sendingTestEmail.value = false
} }
...@@ -3039,8 +3234,8 @@ async function loadAdminApiKey() { ...@@ -3039,8 +3234,8 @@ async function loadAdminApiKey() {
const status = await adminAPI.settings.getAdminApiKey() const status = await adminAPI.settings.getAdminApiKey()
adminApiKeyExists.value = status.exists adminApiKeyExists.value = status.exists
adminApiKeyMasked.value = status.masked_key adminApiKeyMasked.value = status.masked_key
} catch (error: any) { } catch (_error: unknown) {
console.error('Failed to load admin API key status:', error) // Silent fail - admin API key status is non-critical
} finally { } finally {
adminApiKeyLoading.value = false adminApiKeyLoading.value = false
} }
...@@ -3054,8 +3249,8 @@ async function createAdminApiKey() { ...@@ -3054,8 +3249,8 @@ async function createAdminApiKey() {
adminApiKeyExists.value = true adminApiKeyExists.value = true
adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4) adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4)
appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated')) appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError(error.message || t('common.error')) appStore.showError(extractApiErrorMessage(error, t('common.error')))
} finally { } finally {
adminApiKeyOperating.value = false adminApiKeyOperating.value = false
} }
...@@ -3075,8 +3270,8 @@ async function deleteAdminApiKey() { ...@@ -3075,8 +3270,8 @@ async function deleteAdminApiKey() {
adminApiKeyMasked.value = '' adminApiKeyMasked.value = ''
newAdminApiKey.value = '' newAdminApiKey.value = ''
appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted')) appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError(error.message || t('common.error')) appStore.showError(extractApiErrorMessage(error, t('common.error')))
} finally { } finally {
adminApiKeyOperating.value = false adminApiKeyOperating.value = false
} }
...@@ -3099,8 +3294,8 @@ async function loadOverloadCooldownSettings() { ...@@ -3099,8 +3294,8 @@ async function loadOverloadCooldownSettings() {
try { try {
const settings = await adminAPI.settings.getOverloadCooldownSettings() const settings = await adminAPI.settings.getOverloadCooldownSettings()
Object.assign(overloadCooldownForm, settings) Object.assign(overloadCooldownForm, settings)
} catch (error: any) { } catch (_error: unknown) {
console.error('Failed to load overload cooldown settings:', error) // Silent fail - settings will use defaults
} finally { } finally {
overloadCooldownLoading.value = false overloadCooldownLoading.value = false
} }
...@@ -3115,10 +3310,8 @@ async function saveOverloadCooldownSettings() { ...@@ -3115,10 +3310,8 @@ async function saveOverloadCooldownSettings() {
}) })
Object.assign(overloadCooldownForm, updated) Object.assign(overloadCooldownForm, updated)
appStore.showSuccess(t('admin.settings.overloadCooldown.saved')) appStore.showSuccess(t('admin.settings.overloadCooldown.saved'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.overloadCooldown.saveFailed')))
t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
overloadCooldownSaving.value = false overloadCooldownSaving.value = false
} }
...@@ -3130,8 +3323,8 @@ async function loadStreamTimeoutSettings() { ...@@ -3130,8 +3323,8 @@ async function loadStreamTimeoutSettings() {
try { try {
const settings = await adminAPI.settings.getStreamTimeoutSettings() const settings = await adminAPI.settings.getStreamTimeoutSettings()
Object.assign(streamTimeoutForm, settings) Object.assign(streamTimeoutForm, settings)
} catch (error: any) { } catch (_error: unknown) {
console.error('Failed to load stream timeout settings:', error) // Silent fail - settings will use defaults
} finally { } finally {
streamTimeoutLoading.value = false streamTimeoutLoading.value = false
} }
...@@ -3149,10 +3342,8 @@ async function saveStreamTimeoutSettings() { ...@@ -3149,10 +3342,8 @@ async function saveStreamTimeoutSettings() {
}) })
Object.assign(streamTimeoutForm, updated) Object.assign(streamTimeoutForm, updated)
appStore.showSuccess(t('admin.settings.streamTimeout.saved')) appStore.showSuccess(t('admin.settings.streamTimeout.saved'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.streamTimeout.saveFailed')))
t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
streamTimeoutSaving.value = false streamTimeoutSaving.value = false
} }
...@@ -3168,8 +3359,8 @@ async function loadRectifierSettings() { ...@@ -3168,8 +3359,8 @@ async function loadRectifierSettings() {
if (!Array.isArray(rectifierForm.apikey_signature_patterns)) { if (!Array.isArray(rectifierForm.apikey_signature_patterns)) {
rectifierForm.apikey_signature_patterns = [] rectifierForm.apikey_signature_patterns = []
} }
} catch (error: any) { } catch (_error: unknown) {
console.error('Failed to load rectifier settings:', error) // Silent fail - settings will use defaults
} finally { } finally {
rectifierLoading.value = false rectifierLoading.value = false
} }
...@@ -3192,10 +3383,8 @@ async function saveRectifierSettings() { ...@@ -3192,10 +3383,8 @@ async function saveRectifierSettings() {
rectifierForm.apikey_signature_patterns = [] rectifierForm.apikey_signature_patterns = []
} }
appStore.showSuccess(t('admin.settings.rectifier.saved')) appStore.showSuccess(t('admin.settings.rectifier.saved'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.rectifier.saveFailed')))
t('admin.settings.rectifier.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
rectifierSaving.value = false rectifierSaving.value = false
} }
...@@ -3267,8 +3456,8 @@ async function loadBetaPolicySettings() { ...@@ -3267,8 +3456,8 @@ async function loadBetaPolicySettings() {
try { try {
const settings = await adminAPI.settings.getBetaPolicySettings() const settings = await adminAPI.settings.getBetaPolicySettings()
betaPolicyForm.rules = settings.rules betaPolicyForm.rules = settings.rules
} catch (error: any) { } catch (_error: unknown) {
console.error('Failed to load beta policy settings:', error) // Silent fail - settings will use defaults
} finally { } finally {
betaPolicyLoading.value = false betaPolicyLoading.value = false
} }
...@@ -3296,15 +3485,182 @@ async function saveBetaPolicySettings() { ...@@ -3296,15 +3485,182 @@ async function saveBetaPolicySettings() {
}) })
betaPolicyForm.rules = updated.rules betaPolicyForm.rules = updated.rules
appStore.showSuccess(t('admin.settings.betaPolicy.saved')) appStore.showSuccess(t('admin.settings.betaPolicy.saved'))
} catch (error: any) { } catch (error: unknown) {
appStore.showError( appStore.showError(extractApiErrorMessage(error, t('admin.settings.betaPolicy.saveFailed')))
t('admin.settings.betaPolicy.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} finally { } finally {
betaPolicySaving.value = false 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(() => { onMounted(() => {
loadSettings() loadSettings()
loadSubscriptionGroups() loadSubscriptionGroups()
...@@ -3313,6 +3669,7 @@ onMounted(() => { ...@@ -3313,6 +3669,7 @@ onMounted(() => {
loadStreamTimeoutSettings() loadStreamTimeoutSettings()
loadRectifierSettings() loadRectifierSettings()
loadBetaPolicySettings() loadBetaPolicySettings()
loadProviders()
}) })
</script> </script>
......
...@@ -174,6 +174,8 @@ ...@@ -174,6 +174,8 @@
:data="subscriptions" :data="subscriptions"
:loading="loading" :loading="loading"
:server-side-sort="true" :server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort" @sort="handleSort"
> >
<template #cell-user="{ row }"> <template #cell-user="{ row }">
......
...@@ -100,7 +100,16 @@ ...@@ -100,7 +100,16 @@
</div> </div>
</template> </template>
</UsageFilters> </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" /> <Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
</div> </div>
</AppLayout> </AppLayout>
...@@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates() ...@@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates()
const startDate = ref(defaultRange.start); const endDate = ref(defaultRange.end) 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 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 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 => { 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) 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: ...@@ -265,12 +278,31 @@ const onDateRangeChange = (range: { startDate: string; endDate: string; preset:
applyFilters() 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 () => { const loadLogs = async () => {
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
try { try {
const requestType = filters.value.request_type const res = await adminAPI.usage.list(
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream buildUsageListParams(pagination.page, pagination.page_size, false),
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 }) { signal: c.signal }
)
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total } 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 } } 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 = () => { ...@@ -412,6 +444,12 @@ const resetFilters = () => {
} }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() } const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; 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 cancelExport = () => exportAbortController?.abort()
const openCleanupDialog = () => { cleanupDialogVisible.value = true } const openCleanupDialog = () => { cleanupDialogVisible.value = true }
const getRequestTypeLabel = (log: AdminUsageLog): string => { const getRequestTypeLabel = (log: AdminUsageLog): string => {
...@@ -443,9 +481,10 @@ const exportToExcel = async () => { ...@@ -443,9 +481,10 @@ const exportToExcel = async () => {
] ]
const ws = XLSX.utils.aoa_to_sheet([headers]) const ws = XLSX.utils.aoa_to_sheet([headers])
while (true) { while (true) {
const requestType = filters.value.request_type const res = await adminUsageAPI.list(
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream buildUsageListParams(p, 100, true),
const res = await adminUsageAPI.list({ page: p, page_size: 100, exact_total: true, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal }) { signal: c.signal }
)
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [ const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model, log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
......
...@@ -235,7 +235,17 @@ ...@@ -235,7 +235,17 @@
<!-- Users Table --> <!-- Users Table -->
<template #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 }"> <template #cell-email="{ value }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
...@@ -774,6 +784,25 @@ const columns = computed<Column[]>(() => ...@@ -774,6 +784,25 @@ const columns = computed<Column[]>(() =>
const users = ref<AdminUser[]>([]) const users = ref<AdminUser[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') 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 // Groups data for the groups column
const allGroups = ref<AdminGroup[]>([]) const allGroups = ref<AdminGroup[]>([])
...@@ -1125,7 +1154,9 @@ const loadUsers = async () => { ...@@ -1125,7 +1154,9 @@ const loadUsers = async () => {
search: searchQuery.value || undefined, search: searchQuery.value || undefined,
group_name: filters.group || undefined, group_name: filters.group || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : 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 } { signal }
) )
...@@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsers() loadUsers()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadUsers()
}
// Filter helpers // Filter helpers
const getAttributeDefinitionName = (attrId: number): string => { const getAttributeDefinitionName = (attrId: number): string => {
const def = attributeDefinitions.value.find(d => d.id === attrId) const def = attributeDefinitions.value.find(d => d.id === attrId)
......
...@@ -202,7 +202,6 @@ ...@@ -202,7 +202,6 @@
:total="total" :total="total"
:page="page" :page="page"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10]"
@update:page="emit('update:page', $event)" @update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)" @update:pageSize="emit('update:pageSize', $event)"
/> />
......
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