mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
Merge remote-tracking branch 'upstream/main' into feat/payment-system-v2
# Conflicts: # frontend/src/api/admin/settings.ts # frontend/src/stores/app.ts # frontend/src/types/index.ts # frontend/src/views/admin/SettingsView.vue
This commit is contained in:
@@ -38,6 +38,8 @@ export async function list(
|
||||
search?: string
|
||||
privacy_mode?: string
|
||||
lite?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -71,6 +73,8 @@ export async function listWithEtag(
|
||||
search?: string
|
||||
privacy_mode?: string
|
||||
lite?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -500,7 +504,11 @@ export async function exportData(options?: {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
group?: string
|
||||
privacy_mode?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
includeProxies?: boolean
|
||||
}): Promise<AdminDataPayload> {
|
||||
@@ -508,11 +516,15 @@ export async function exportData(options?: {
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { platform, type, status, search } = options.filters
|
||||
const { platform, type, status, group, privacy_mode, search, sort_by, sort_order } = options.filters
|
||||
if (platform) params.platform = platform
|
||||
if (type) params.type = type
|
||||
if (status) params.status = status
|
||||
if (group) params.group = group
|
||||
if (privacy_mode) params.privacy_mode = privacy_mode
|
||||
if (search) params.search = search
|
||||
if (sort_by) params.sort_by = sort_by
|
||||
if (sort_order) params.sort_order = sort_order
|
||||
}
|
||||
if (options?.includeProxies === false) {
|
||||
params.include_proxies = 'false'
|
||||
|
||||
@@ -17,10 +17,16 @@ export async function list(
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<Announcement>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
|
||||
params: { page, page_size: pageSize, ...filters }
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -49,11 +55,21 @@ export async function getReadStatus(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
search: string = ''
|
||||
filters?: {
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
|
||||
`/admin/announcements/${id}/read-status`,
|
||||
{ params: { page, page_size: pageSize, search } }
|
||||
{
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
@@ -68,4 +84,3 @@ const announcementsAPI = {
|
||||
}
|
||||
|
||||
export default announcementsAPI
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ export async function list(
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<Channel>> {
|
||||
|
||||
@@ -27,6 +27,8 @@ export async function list(
|
||||
status?: 'active' | 'inactive'
|
||||
is_exclusive?: boolean
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
|
||||
@@ -17,10 +17,16 @@ export async function list(
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<PromoCode>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
|
||||
params: { page, page_size: pageSize, ...filters }
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export async function list(
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -227,16 +229,20 @@ export async function exportData(options?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
}): Promise<AdminDataPayload> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { protocol, status, search } = options.filters
|
||||
const { protocol, status, search, sort_by, sort_order } = options.filters
|
||||
if (protocol) params.protocol = protocol
|
||||
if (status) params.status = status
|
||||
if (search) params.search = search
|
||||
if (sort_by) params.sort_by = sort_by
|
||||
if (sort_order) params.sort_order = sort_order
|
||||
}
|
||||
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
|
||||
return data
|
||||
|
||||
@@ -25,6 +25,8 @@ export async function list(
|
||||
type?: RedeemCodeType
|
||||
status?: 'active' | 'used' | 'expired' | 'unused'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -151,7 +153,10 @@ export async function getStats(): Promise<{
|
||||
*/
|
||||
export async function exportCodes(filters?: {
|
||||
type?: RedeemCodeType
|
||||
status?: 'active' | 'used' | 'expired'
|
||||
status?: 'used' | 'expired' | 'unused'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}): Promise<Blob> {
|
||||
const response = await apiClient.get('/admin/redeem-codes/export', {
|
||||
params: filters,
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface SystemSettings {
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
table_default_page_size: number
|
||||
table_page_size_options: number[]
|
||||
backend_mode_enabled: boolean
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
@@ -154,6 +156,8 @@ export interface UpdateSettingsRequest {
|
||||
doc_url?: string
|
||||
home_content?: string
|
||||
hide_ccs_import_button?: boolean
|
||||
table_default_page_size?: number
|
||||
table_page_size_options?: number[]
|
||||
backend_mode_enabled?: boolean
|
||||
custom_menu_items?: CustomMenuItem[]
|
||||
custom_endpoints?: CustomEndpoint[]
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number
|
||||
exact_total?: boolean
|
||||
billing_mode?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
@@ -24,6 +24,8 @@ export async function list(
|
||||
group_name?: string // fuzzy filter by allowed group name
|
||||
attributes?: Record<number, string> // attributeId -> value
|
||||
include_subscriptions?: boolean
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
@@ -37,7 +39,9 @@ export async function list(
|
||||
role: filters?.role,
|
||||
search: filters?.search,
|
||||
group_name: filters?.group_name,
|
||||
include_subscriptions: filters?.include_subscriptions
|
||||
include_subscriptions: filters?.include_subscriptions,
|
||||
sort_by: filters?.sort_by,
|
||||
sort_order: filters?.sort_order
|
||||
}
|
||||
|
||||
// Add attribute filters as attr[id]=value
|
||||
|
||||
@@ -17,7 +17,13 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filters?: { search?: string; status?: string; group_id?: number | string },
|
||||
filters?: {
|
||||
search?: string
|
||||
status?: string
|
||||
group_id?: number | string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function list(
|
||||
* @returns Paginated list of usage logs
|
||||
*/
|
||||
export async function query(
|
||||
params: UsageQueryParams,
|
||||
params: UsageQueryParams & { sort_by?: string; sort_order?: 'asc' | 'desc' },
|
||||
config: { signal?: AbortSignal } = {}
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
|
||||
@@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
|
||||
const privacyOpts = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allPrivacyModes') },
|
||||
{ value: '__unset__', label: t('admin.accounts.privacyUnset') },
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import AccountTableFilters from '../AccountTableFilters.vue'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('AccountTableFilters', () => {
|
||||
it('renders privacy mode options and emits privacy_mode updates', async () => {
|
||||
const wrapper = mount(AccountTableFilters, {
|
||||
props: {
|
||||
searchQuery: '',
|
||||
filters: {
|
||||
platform: '',
|
||||
type: '',
|
||||
status: '',
|
||||
group: '',
|
||||
privacy_mode: ''
|
||||
},
|
||||
groups: []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SearchInput: {
|
||||
template: '<div />'
|
||||
},
|
||||
Select: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
template: '<div class="select-stub" :data-options="JSON.stringify(options)" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const selects = wrapper.findAll('.select-stub')
|
||||
expect(selects).toHaveLength(5)
|
||||
|
||||
const privacyOptions = JSON.parse(selects[3].attributes('data-options'))
|
||||
expect(privacyOptions).toEqual([
|
||||
{ value: '', label: 'admin.accounts.allPrivacyModes' },
|
||||
{ value: '__unset__', label: 'admin.accounts.privacyUnset' },
|
||||
{ value: 'training_off', label: 'Privacy' },
|
||||
{ value: 'training_set_cf_blocked', label: 'CF' },
|
||||
{ value: 'training_set_failed', label: 'Fail' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="items" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="email"
|
||||
default-sort-order="asc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-email="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -62,7 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
@@ -98,23 +106,54 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const sortState = reactive({
|
||||
sort_by: 'email',
|
||||
sort_order: 'asc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const items = ref<AnnouncementUserReadStatus[]>([])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'email', label: t('common.email') },
|
||||
{ key: 'username', label: t('admin.users.columns.username') },
|
||||
{ key: 'balance', label: t('common.balance') },
|
||||
{ key: 'email', label: t('common.email'), sortable: true },
|
||||
{ key: 'username', label: t('admin.users.columns.username'), sortable: true },
|
||||
{ key: 'balance', label: t('common.balance'), sortable: true },
|
||||
{ key: 'eligible', label: t('admin.announcements.eligible') },
|
||||
{ key: 'read_at', label: t('admin.announcements.readAt') }
|
||||
])
|
||||
|
||||
let currentController: AbortController | null = null
|
||||
let searchDebounceTimer: number | null = null
|
||||
|
||||
function resetDialogState() {
|
||||
loading.value = false
|
||||
search.value = ''
|
||||
items.value = []
|
||||
pagination.page = 1
|
||||
pagination.total = 0
|
||||
pagination.pages = 0
|
||||
sortState.sort_by = 'email'
|
||||
sortState.sort_order = 'asc'
|
||||
}
|
||||
|
||||
function cancelPendingLoad(resetState = false) {
|
||||
if (searchDebounceTimer) {
|
||||
window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
currentController?.abort()
|
||||
currentController = null
|
||||
if (resetState) {
|
||||
resetDialogState()
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.show || !props.announcementId) return
|
||||
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
currentController?.abort()
|
||||
const requestController = new AbortController()
|
||||
currentController = requestController
|
||||
const { signal } = requestController
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -122,20 +161,37 @@ async function load() {
|
||||
props.announcementId,
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
search.value
|
||||
{
|
||||
search: search.value,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
if (signal.aborted || currentController !== requestController) return
|
||||
|
||||
items.value = res.items
|
||||
pagination.total = res.total
|
||||
pagination.pages = res.pages
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
if (
|
||||
signal.aborted ||
|
||||
currentController !== requestController ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to load read status:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (currentController === requestController) {
|
||||
loading.value = false
|
||||
currentController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
|
||||
load()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
@@ -160,13 +222,17 @@ function handleSearch() {
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
cancelPendingLoad(true)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
if (!v) {
|
||||
cancelPendingLoad(true)
|
||||
return
|
||||
}
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
@@ -181,7 +247,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// noop
|
||||
onUnmounted(() => {
|
||||
cancelPendingLoad()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import AnnouncementReadStatusDialog from '../AnnouncementReadStatusDialog.vue'
|
||||
|
||||
const { getReadStatus, showError } = vi.hoisted(() => ({
|
||||
getReadStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
announcements: {
|
||||
getReadStatus,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/usePersistedPageSize', () => ({
|
||||
getPersistedPageSize: () => 20,
|
||||
}))
|
||||
|
||||
const BaseDialogStub = {
|
||||
props: ['show', 'title', 'width'],
|
||||
emits: ['close'],
|
||||
template: '<div><slot /><slot name="footer" /></div>',
|
||||
}
|
||||
|
||||
describe('AnnouncementReadStatusDialog', () => {
|
||||
beforeEach(() => {
|
||||
getReadStatus.mockReset()
|
||||
showError.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('closes by aborting active requests and clearing debounced reloads', async () => {
|
||||
let activeSignal: AbortSignal | undefined
|
||||
getReadStatus.mockImplementation(async (...args: any[]) => {
|
||||
activeSignal = args[4]?.signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const wrapper = mount(AnnouncementReadStatusDialog, {
|
||||
props: {
|
||||
show: false,
|
||||
announcementId: 1,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
DataTable: true,
|
||||
Pagination: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.setProps({ show: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(getReadStatus).toHaveBeenCalledTimes(1)
|
||||
expect(activeSignal?.aborted).toBe(false)
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.search = 'alice'
|
||||
setupState.handleSearch()
|
||||
|
||||
setupState.handleClose()
|
||||
await flushPromises()
|
||||
|
||||
expect(activeSignal?.aborted).toBe(true)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(350)
|
||||
await flushPromises()
|
||||
|
||||
expect(getReadStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -196,7 +196,6 @@
|
||||
:total="localEntries.length"
|
||||
:page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@update:page="currentPage = $event"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-auto">
|
||||
<DataTable :columns="columns" :data="data" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:server-side-sort="serverSideSort"
|
||||
:default-sort-key="defaultSortKey"
|
||||
:default-sort-order="defaultSortOrder"
|
||||
@sort="(key, order) => $emit('sort', key, order)"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<button
|
||||
@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
defineProps(['data', 'loading', 'columns'])
|
||||
defineEmits(['userClick'])
|
||||
interface Props {
|
||||
data: AdminUsageLog[]
|
||||
loading?: boolean
|
||||
columns: Column[]
|
||||
serverSideSort?: boolean
|
||||
defaultSortKey?: string
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
serverSideSort: false,
|
||||
defaultSortKey: '',
|
||||
defaultSortOrder: 'asc'
|
||||
})
|
||||
defineEmits<{
|
||||
userClick: [userID: number, email?: string]
|
||||
sort: [key: string, order: 'asc' | 'desc']
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Tooltip state - cost
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
'is-scrollable': isScrollable
|
||||
}"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<table class="w-full min-w-max divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="table-header bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<th
|
||||
@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col {
|
||||
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* ==========================================================================
|
||||
终极悬浮滚动条防丢器 (Sledgehammer Override)
|
||||
绕过 style.css 中 `* { scrollbar-color: transparent }` 的全局悬停隐身诅咒!
|
||||
========================================================================== */
|
||||
|
||||
/* 1. 废除全局针对所有元素的 scrollbar-width 设定,拿回 Chrome/Safari 下 Webkit 滚动条规则的控制权! */
|
||||
.table-wrapper {
|
||||
scrollbar-width: auto !important; /* 阻止 Chrome 121 退化到原生 Mac 闪隐滚动条 */
|
||||
}
|
||||
|
||||
/* 2. 重写 Webkit 滚动层,全部加上 !important 强制覆盖透明悬停陷阱 */
|
||||
.table-wrapper::-webkit-scrollbar {
|
||||
height: 12px !important;
|
||||
width: 12px !important;
|
||||
display: block !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.table-wrapper::-webkit-scrollbar-track {
|
||||
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||
border-radius: 6px !important;
|
||||
margin: 0 4px !important;
|
||||
}
|
||||
.dark .table-wrapper::-webkit-scrollbar-track {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
/* 常驻、不透明的滑块,无视鼠标是否 hover 都在那! */
|
||||
.table-wrapper::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(107, 114, 128, 0.75) !important;
|
||||
border-radius: 6px !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: padding-box !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .table-wrapper::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.75) !important;
|
||||
}
|
||||
.dark .table-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(209, 213, 219, 0.9) !important;
|
||||
}
|
||||
|
||||
/* 3. 仅给真正的 Firefox 留的后路 */
|
||||
@supports (-moz-appearance:none) {
|
||||
.table-wrapper {
|
||||
scrollbar-width: thin !important;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
.dark .table-wrapper {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,7 +122,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from './Select.vue'
|
||||
import { setPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -141,7 +141,7 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100],
|
||||
pageSizeOptions: () => getConfiguredTablePageSizeOptions(),
|
||||
showPageSizeSelector: true,
|
||||
showJump: false
|
||||
})
|
||||
@@ -161,7 +161,14 @@ const toItem = computed(() => {
|
||||
})
|
||||
|
||||
const pageSizeSelectOptions = computed(() => {
|
||||
return props.pageSizeOptions.map((size) => ({
|
||||
const options = Array.from(
|
||||
new Set([
|
||||
...getConfiguredTablePageSizeOptions(),
|
||||
normalizeTablePageSize(props.pageSize)
|
||||
])
|
||||
).sort((a, b) => a - b)
|
||||
|
||||
return options.map((size) => ({
|
||||
value: size,
|
||||
label: String(size)
|
||||
}))
|
||||
@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => {
|
||||
|
||||
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||
if (value === null || typeof value === 'boolean') return
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
setPersistedPageSize(newPageSize)
|
||||
const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value)
|
||||
emit('update:pageSize', newPageSize)
|
||||
}
|
||||
|
||||
|
||||
@@ -804,11 +804,14 @@ onMounted(() => {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Custom SVG icon in sidebar: inherit color, constrain size */
|
||||
/* Custom SVG icon in sidebar: constrain size without overriding uploaded SVG colors */
|
||||
.sidebar-svg-icon {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.sidebar-svg-icon :deep(svg) {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
18
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
Normal file
18
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AppSidebar.vue')
|
||||
const componentSource = readFileSync(componentPath, 'utf8')
|
||||
|
||||
describe('AppSidebar custom SVG styles', () => {
|
||||
it('does not override uploaded SVG fill or stroke colors', () => {
|
||||
expect(componentSource).toContain('.sidebar-svg-icon {')
|
||||
expect(componentSource).toContain('color: currentColor;')
|
||||
expect(componentSource).toContain('display: block;')
|
||||
expect(componentSource).not.toContain('stroke: currentColor;')
|
||||
expect(componentSource).not.toContain('fill: none;')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
|
||||
describe('usePersistedPageSize', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
delete window.__APP_CONFIG__
|
||||
})
|
||||
|
||||
it('uses the system table default instead of stale localStorage state', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 1000,
|
||||
table_page_size_options: [20, 50, 1000]
|
||||
} as any
|
||||
localStorage.setItem('table-page-size', '50')
|
||||
localStorage.setItem('table-page-size-source', 'user')
|
||||
|
||||
expect(getPersistedPageSize()).toBe(1000)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,9 @@
|
||||
const STORAGE_KEY = 'table-page-size'
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取/写入 pageSize
|
||||
* 全局共享一个 key,所有表格统一偏好
|
||||
* 读取当前系统配置的表格默认每页条数。
|
||||
* 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
|
||||
*/
|
||||
export function getPersistedPageSize(fallback = DEFAULT_PAGE_SIZE): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = Number(stored)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
} catch {
|
||||
// localStorage 不可用(隐私模式等)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function setPersistedPageSize(size: number): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(size))
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
|
||||
return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, reactive, onUnmounted, toRaw } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { BasePaginationResponse, FetchOptions } from '@/types'
|
||||
import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize'
|
||||
import { getPersistedPageSize } from './usePersistedPageSize'
|
||||
|
||||
interface PaginationState {
|
||||
page: number
|
||||
@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
setPersistedPageSize(size)
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
@@ -2057,6 +2057,7 @@ export default {
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
unschedulable: 'Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
|
||||
rateLimitedAutoResume: 'Auto resumes in {time}',
|
||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||
@@ -4359,6 +4360,15 @@ export default {
|
||||
apiBaseUrlPlaceholder: 'https://api.example.com',
|
||||
apiBaseUrlHint:
|
||||
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
|
||||
tablePreferencesTitle: 'Global Table Preferences',
|
||||
tablePreferencesDescription: 'Configure default pagination behavior for shared table components',
|
||||
tableDefaultPageSize: 'Default Rows Per Page',
|
||||
tableDefaultPageSizeHint: 'Must be an integer between 5 and 1000',
|
||||
tablePageSizeOptions: 'Rows Per Page Options',
|
||||
tablePageSizeOptionsPlaceholder: '10, 20, 50, 100',
|
||||
tablePageSizeOptionsHint: 'Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save',
|
||||
tableDefaultPageSizeRangeError: 'Default rows per page must be between {min} and {max}',
|
||||
tablePageSizeOptionsFormatError: 'Invalid options format. Enter comma-separated integers between {min} and {max}',
|
||||
customEndpoints: {
|
||||
title: 'Custom Endpoints',
|
||||
description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page',
|
||||
|
||||
@@ -2240,6 +2240,7 @@ export default {
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
unschedulable: '不可调度',
|
||||
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
|
||||
rateLimitedAutoResume: '{time} 自动恢复',
|
||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||
@@ -4520,6 +4521,15 @@ export default {
|
||||
apiBaseUrl: 'API 端点地址',
|
||||
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
|
||||
apiBaseUrlPlaceholder: 'https://api.example.com',
|
||||
tablePreferencesTitle: '通用表格设置',
|
||||
tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为',
|
||||
tableDefaultPageSize: '默认每页条数',
|
||||
tableDefaultPageSizeHint: '必须为 5-1000 之间的整数',
|
||||
tablePageSizeOptions: '可选每页条数列表',
|
||||
tablePageSizeOptionsPlaceholder: '10, 20, 50, 100',
|
||||
tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序',
|
||||
tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间',
|
||||
tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔',
|
||||
customEndpoints: {
|
||||
title: '自定义端点',
|
||||
description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
|
||||
// Mock API 模块
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
@@ -15,12 +16,14 @@ describe('useAppStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
localStorage.clear()
|
||||
// 清除 window.__APP_CONFIG__
|
||||
delete (window as any).__APP_CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// --- Toast 消息管理 ---
|
||||
@@ -291,5 +294,43 @@ describe('useAppStore', () => {
|
||||
expect(store.publicSettingsLoaded).toBe(false)
|
||||
expect(store.cachedPublicSettings).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchPublicSettings(force) 会同步更新运行时注入配置', async () => {
|
||||
vi.mocked(getPublicSettings).mockResolvedValue({
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
registration_email_suffix_whitelist: [],
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
invitation_code_enabled: false,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
site_name: 'Updated Site',
|
||||
site_logo: '',
|
||||
site_subtitle: '',
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
table_default_page_size: 1000,
|
||||
table_page_size_options: [20, 100, 1000],
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
version: '1.0.0'
|
||||
})
|
||||
|
||||
const store = useAppStore()
|
||||
await store.fetchPublicSettings(true)
|
||||
|
||||
expect((window as any).__APP_CONFIG__.table_default_page_size).toBe(1000)
|
||||
expect((window as any).__APP_CONFIG__.table_page_size_options).toEqual([20, 100, 1000])
|
||||
expect(localStorage.getItem('table-page-size')).toBeNull()
|
||||
expect(localStorage.getItem('table-page-size-source')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
* Apply settings to store state (internal helper to avoid code duplication)
|
||||
*/
|
||||
function applySettings(config: PublicSettings): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__APP_CONFIG__ = { ...config }
|
||||
}
|
||||
cachedPublicSettings.value = config
|
||||
siteName.value = config.site_name || 'Sub2API'
|
||||
siteLogo.value = config.site_logo || ''
|
||||
@@ -327,9 +330,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
payment_enabled: false,
|
||||
table_default_page_size: 20,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
|
||||
@@ -16,20 +16,22 @@
|
||||
@apply min-h-screen;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
/* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */
|
||||
@supports (-moz-appearance:none) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*:hover,
|
||||
*:focus-within {
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
}
|
||||
*:hover,
|
||||
*:focus-within {
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
}
|
||||
|
||||
.dark *:hover,
|
||||
.dark *:focus-within {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
|
||||
.dark *:hover,
|
||||
.dark *:focus-within {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -58,36 +60,7 @@
|
||||
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
|
||||
}
|
||||
|
||||
/*
|
||||
* 表格滚动容器:始终显示滚动条,不跟随全局悬停策略。
|
||||
*
|
||||
* 浏览器兼容性说明:
|
||||
* - Chrome 121+ 原生支持 scrollbar-color / scrollbar-width。
|
||||
* 一旦元素匹配了这两个标准属性,::-webkit-scrollbar-* 被完全忽略。
|
||||
* 全局 * { scrollbar-width: thin } 使所有元素都走标准属性,
|
||||
* 因此 Chrome 121+ 只看 scrollbar-color。
|
||||
* - Chrome < 121 不认识标准属性,只看 ::-webkit-scrollbar-*,
|
||||
* 所以保留 ::-webkit-scrollbar-thumb 作为回退。
|
||||
* - Firefox 始终只看 scrollbar-color / scrollbar-width。
|
||||
*/
|
||||
.table-wrapper {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.7) transparent;
|
||||
}
|
||||
.dark .table-wrapper {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.7) transparent;
|
||||
}
|
||||
/* 旧版 Chrome (< 121) 兼容回退 */
|
||||
.table-wrapper::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.table-wrapper::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-gray-400/70;
|
||||
}
|
||||
.dark .table-wrapper::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-gray-500/70;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -104,9 +104,9 @@ export interface PublicSettings {
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
payment_enabled: boolean
|
||||
table_default_page_size: number
|
||||
table_page_size_options: number[]
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
linuxdo_oauth_enabled: boolean
|
||||
@@ -1364,6 +1364,8 @@ export interface UsageQueryParams {
|
||||
billing_type?: number | null
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// ==================== Account Usage Statistics ====================
|
||||
|
||||
74
frontend/src/utils/__tests__/tablePreferences.spec.ts
Normal file
74
frontend/src/utils/__tests__/tablePreferences.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE,
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
getConfiguredTableDefaultPageSize,
|
||||
getConfiguredTablePageSizeOptions,
|
||||
normalizeTablePageSize
|
||||
} from '@/utils/tablePreferences'
|
||||
|
||||
describe('tablePreferences', () => {
|
||||
afterEach(() => {
|
||||
delete window.__APP_CONFIG__
|
||||
})
|
||||
|
||||
it('returns built-in defaults when app config is missing', () => {
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(DEFAULT_TABLE_PAGE_SIZE)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual(DEFAULT_TABLE_PAGE_SIZE_OPTIONS)
|
||||
})
|
||||
|
||||
it('uses configured defaults when app config is valid', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 50,
|
||||
table_page_size_options: [20, 50, 100]
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(50)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([20, 50, 100])
|
||||
})
|
||||
|
||||
it('allows default page size outside selectable options', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 1000,
|
||||
table_page_size_options: [20, 50, 100]
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(1000)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([20, 50, 100])
|
||||
expect(normalizeTablePageSize(1000)).toBe(100)
|
||||
expect(normalizeTablePageSize(35)).toBe(50)
|
||||
})
|
||||
|
||||
it('normalizes invalid options without rewriting the configured default itself', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 35,
|
||||
table_page_size_options: [1001, 50, 10, 10, 2, 0]
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTableDefaultPageSize()).toBe(35)
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([10, 50])
|
||||
expect(normalizeTablePageSize(undefined)).toBe(50)
|
||||
})
|
||||
|
||||
it('normalizes page size against configured options by rounding up', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 20,
|
||||
table_page_size_options: [20, 50, 1000]
|
||||
} as any
|
||||
|
||||
expect(normalizeTablePageSize(20)).toBe(20)
|
||||
expect(normalizeTablePageSize(30)).toBe(50)
|
||||
expect(normalizeTablePageSize(100)).toBe(1000)
|
||||
expect(normalizeTablePageSize(1500)).toBe(1000)
|
||||
expect(normalizeTablePageSize(undefined)).toBe(20)
|
||||
})
|
||||
|
||||
it('keeps built-in selectable defaults at 10, 20, 50, 100', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
table_default_page_size: 1000
|
||||
} as any
|
||||
|
||||
expect(getConfiguredTablePageSizeOptions()).toEqual([10, 20, 50, 100])
|
||||
})
|
||||
})
|
||||
73
frontend/src/utils/tablePreferences.ts
Normal file
73
frontend/src/utils/tablePreferences.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const MIN_TABLE_PAGE_SIZE = 5
|
||||
const MAX_TABLE_PAGE_SIZE = 1000
|
||||
|
||||
export const DEFAULT_TABLE_PAGE_SIZE = 20
|
||||
export const DEFAULT_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
||||
|
||||
const sanitizePageSize = (value: unknown): number | null => {
|
||||
const size = Number(value)
|
||||
if (!Number.isInteger(size)) return null
|
||||
if (size < MIN_TABLE_PAGE_SIZE || size > MAX_TABLE_PAGE_SIZE) return null
|
||||
return size
|
||||
}
|
||||
|
||||
const parsePageSizeForSelection = (value: unknown): number | null => {
|
||||
const size = Number(value)
|
||||
if (!Number.isInteger(size)) return null
|
||||
if (size < MIN_TABLE_PAGE_SIZE) return null
|
||||
return size
|
||||
}
|
||||
|
||||
const getInjectedAppConfig = () => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.__APP_CONFIG__ ?? null
|
||||
}
|
||||
|
||||
const getSanitizedConfiguredOptions = (): number[] => {
|
||||
const configured = getInjectedAppConfig()?.table_page_size_options
|
||||
if (!Array.isArray(configured)) return []
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
configured
|
||||
.map((value) => sanitizePageSize(value))
|
||||
.filter((value): value is number => value !== null)
|
||||
)
|
||||
).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
const normalizePageSizeToOptions = (value: number, options: number[]): number => {
|
||||
for (const option of options) {
|
||||
if (option >= value) {
|
||||
return option
|
||||
}
|
||||
}
|
||||
return options[options.length - 1]
|
||||
}
|
||||
|
||||
export const getConfiguredTableDefaultPageSize = (): number => {
|
||||
const configured = sanitizePageSize(getInjectedAppConfig()?.table_default_page_size)
|
||||
if (configured === null) {
|
||||
return DEFAULT_TABLE_PAGE_SIZE
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
export const getConfiguredTablePageSizeOptions = (): number[] => {
|
||||
const unique = getSanitizedConfiguredOptions()
|
||||
if (unique.length === 0) {
|
||||
return [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
|
||||
}
|
||||
|
||||
return unique.length > 0 ? unique : [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
|
||||
}
|
||||
|
||||
export const normalizeTablePageSize = (value: unknown): number => {
|
||||
const normalized = parsePageSizeForSelection(value)
|
||||
const defaultSize = getConfiguredTableDefaultPageSize()
|
||||
const options = getConfiguredTablePageSizeOptions()
|
||||
if (normalized !== null) {
|
||||
return normalizePageSizeToOptions(normalized, options)
|
||||
}
|
||||
return normalizePageSizeToOptions(defaultSize, options)
|
||||
}
|
||||
@@ -148,6 +148,8 @@
|
||||
:data="accounts"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:server-side-sort="true"
|
||||
@sort="handleSort"
|
||||
default-sort-key="name"
|
||||
default-sort-order="asc"
|
||||
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||
@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||
|
||||
// Sorting settings
|
||||
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
|
||||
type AccountSortOrder = 'asc' | 'desc'
|
||||
type AccountSortState = {
|
||||
sort_by: string
|
||||
sort_order: AccountSortOrder
|
||||
}
|
||||
const ACCOUNT_SORTABLE_KEYS = new Set([
|
||||
'name',
|
||||
'status',
|
||||
'schedulable',
|
||||
'priority',
|
||||
'rate_multiplier',
|
||||
'last_used_at',
|
||||
'expires_at'
|
||||
])
|
||||
const loadInitialAccountSortState = (): AccountSortState => {
|
||||
const fallback: AccountSortState = { sort_by: 'name', sort_order: 'asc' }
|
||||
try {
|
||||
const raw = localStorage.getItem(ACCOUNT_SORT_STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const parsed = JSON.parse(raw) as { key?: string; order?: string }
|
||||
const key = typeof parsed.key === 'string' ? parsed.key : ''
|
||||
if (!ACCOUNT_SORTABLE_KEYS.has(key)) return fallback
|
||||
return {
|
||||
sort_by: key,
|
||||
sort_order: parsed.order === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
const sortState = reactive<AccountSortState>(loadInitialAccountSortState())
|
||||
|
||||
// Auto refresh settings
|
||||
const showAutoRefreshDropdown = ref(false)
|
||||
@@ -594,7 +627,16 @@ const {
|
||||
handlePageSizeChange: baseHandlePageSizeChange
|
||||
} = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', privacy_mode: '', group: '', search: '' }
|
||||
initialParams: {
|
||||
platform: '',
|
||||
type: '',
|
||||
status: '',
|
||||
privacy_mode: '',
|
||||
group: '',
|
||||
search: '',
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
|
||||
baseHandlePageSizeChange(size)
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: AccountSortOrder) => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
const requestParams = params as any
|
||||
requestParams.sort_by = key
|
||||
requestParams.sort_order = order
|
||||
pagination.page = 1
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
pendingTodayStatsRefresh.value = true
|
||||
load()
|
||||
}
|
||||
|
||||
watch(loading, (isLoading, wasLoading) => {
|
||||
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
|
||||
pendingTodayStatsRefresh.value = false
|
||||
@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
|
||||
privacy_mode?: string
|
||||
group?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: AccountSortOrder
|
||||
|
||||
},
|
||||
{ etag: autoRefreshETag.value }
|
||||
@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
||||
}
|
||||
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
|
||||
const handleDataImported = () => { showImportData.value = false; reload() }
|
||||
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
|
||||
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
|
||||
const buildAccountQueryFilters = () => ({
|
||||
platform: params.platform || '',
|
||||
type: params.type || '',
|
||||
status: params.status || '',
|
||||
group: params.group || '',
|
||||
privacy_mode: params.privacy_mode || '',
|
||||
search: params.search || '',
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
const accountMatchesCurrentFilters = (account: Account) => {
|
||||
if (params.platform && account.platform !== params.platform) return false
|
||||
if (params.type && account.type !== params.type) return false
|
||||
if (params.status) {
|
||||
if (params.status === 'rate_limited') {
|
||||
if (!account.rate_limit_reset_at) return false
|
||||
const resetAt = new Date(account.rate_limit_reset_at).getTime()
|
||||
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false
|
||||
} else if (account.status !== params.status) {
|
||||
const filters = buildAccountQueryFilters()
|
||||
if (filters.platform && account.platform !== filters.platform) return false
|
||||
if (filters.type && account.type !== filters.type) return false
|
||||
if (filters.status) {
|
||||
const now = Date.now()
|
||||
const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
|
||||
const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
|
||||
const tempUnschedUntil = account.temp_unschedulable_until ? new Date(account.temp_unschedulable_until).getTime() : Number.NaN
|
||||
const isTempUnschedulable = Number.isFinite(tempUnschedUntil) && tempUnschedUntil > now
|
||||
|
||||
if (filters.status === 'active') {
|
||||
if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false
|
||||
} else if (filters.status === 'rate_limited') {
|
||||
if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false
|
||||
} else if (filters.status === 'temp_unschedulable') {
|
||||
if (account.status !== 'active' || !isTempUnschedulable) return false
|
||||
} else if (filters.status === 'unschedulable') {
|
||||
if (account.status !== 'active' || account.schedulable || isRateLimited || isTempUnschedulable) return false
|
||||
} else if (account.status !== filters.status) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const search = String(params.search || '').trim().toLowerCase()
|
||||
if (filters.group) {
|
||||
const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? []
|
||||
if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) {
|
||||
if (groupIds.length > 0) return false
|
||||
} else if (!groupIds.includes(Number(filters.group))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
|
||||
if (filters.privacy_mode) {
|
||||
if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) {
|
||||
if (privacyMode.trim() !== '') return false
|
||||
} else if (privacyMode !== filters.privacy_mode) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const search = String(filters.search || '').trim().toLowerCase()
|
||||
if (search && !account.name.toLowerCase().includes(search)) return false
|
||||
return true
|
||||
}
|
||||
@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
|
||||
? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
|
||||
: {
|
||||
includeProxies: includeProxyOnExport.value,
|
||||
filters: {
|
||||
platform: params.platform,
|
||||
type: params.type,
|
||||
status: params.status,
|
||||
search: params.search
|
||||
}
|
||||
filters: buildAccountQueryFilters()
|
||||
}
|
||||
)
|
||||
const timestamp = formatExportTimestamp()
|
||||
|
||||
@@ -39,7 +39,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="announcements" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="announcements"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-title="{ value, row }">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -68,7 +76,7 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-notifyMode="{ row }">
|
||||
<template #cell-notify_mode="{ row }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
@@ -100,7 +108,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ value }">
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
@@ -236,7 +244,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
@@ -276,6 +284,11 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.announcements.allStatus') },
|
||||
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
|
||||
@@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [
|
||||
])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'title', label: t('admin.announcements.columns.title') },
|
||||
{ key: 'status', label: t('admin.announcements.columns.status') },
|
||||
{ key: 'notifyMode', label: t('admin.announcements.columns.notifyMode') },
|
||||
{ key: 'title', label: t('admin.announcements.columns.title'), sortable: true },
|
||||
{ key: 'status', label: t('admin.announcements.columns.status'), sortable: true },
|
||||
{ key: 'notify_mode', label: t('admin.announcements.columns.notifyMode'), sortable: true },
|
||||
{ key: 'targeting', label: t('admin.announcements.columns.targeting') },
|
||||
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
|
||||
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
|
||||
{ key: 'created_at', label: t('admin.announcements.columns.createdAt'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.announcements.columns.actions') }
|
||||
])
|
||||
|
||||
@@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => {
|
||||
let currentController: AbortController | null = null
|
||||
|
||||
async function loadAnnouncements() {
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
currentController?.abort()
|
||||
const requestController = new AbortController()
|
||||
currentController = requestController
|
||||
const { signal } = requestController
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
})
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}, { signal })
|
||||
|
||||
if (signal.aborted || currentController !== requestController) return
|
||||
|
||||
announcements.value = res.items
|
||||
pagination.total = res.total
|
||||
@@ -337,11 +356,21 @@ async function loadAnnouncements() {
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
if (
|
||||
signal.aborted ||
|
||||
currentController !== requestController ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error('Error loading announcements:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (currentController === requestController) {
|
||||
loading.value = false
|
||||
currentController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +390,13 @@ function handleStatusChange() {
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
@@ -562,4 +598,9 @@ onMounted(async () => {
|
||||
await loadSubscriptionGroups()
|
||||
await loadAnnouncements()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
currentController?.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="channels" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="channels"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -486,6 +494,10 @@ const pagination = reactive({
|
||||
page_size: getPersistedPageSize(),
|
||||
total: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
// Dialog state
|
||||
const showDialog = ref(false)
|
||||
@@ -766,7 +778,9 @@ async function loadChannels() {
|
||||
try {
|
||||
const response = await adminAPI.channels.list(pagination.page, pagination.page_size, {
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}, { signal: ctrl.signal })
|
||||
|
||||
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||||
@@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) {
|
||||
loadChannels()
|
||||
}
|
||||
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadChannels()
|
||||
}
|
||||
|
||||
// ── Dialog ──
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
|
||||
@@ -81,7 +81,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="groups" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="groups"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="sort_order"
|
||||
default-sort-order="asc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
value
|
||||
@@ -2924,6 +2932,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0,
|
||||
});
|
||||
const sortState = reactive({
|
||||
sort_by: "sort_order",
|
||||
sort_order: "asc" as "asc" | "desc",
|
||||
});
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
@@ -3290,6 +3302,8 @@ const loadGroups = async () => {
|
||||
? filters.is_exclusive === "true"
|
||||
: undefined,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -3392,6 +3406,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key;
|
||||
sortState.sort_order = order;
|
||||
pagination.page = 1;
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false;
|
||||
createModelRoutingRules.value.forEach((rule) => {
|
||||
|
||||
@@ -39,7 +39,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="codes"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-code="{ value }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
|
||||
@@ -349,7 +357,6 @@
|
||||
:page="usagesPage"
|
||||
:total="usagesTotal"
|
||||
:page-size="usagesPageSize"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
@update:page="handleUsagesPageChange"
|
||||
@update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
|
||||
/>
|
||||
@@ -418,6 +425,10 @@ const pagination = reactive({
|
||||
page_size: getPersistedPageSize(),
|
||||
total: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
// Dialogs
|
||||
const showCreateDialog = ref(false)
|
||||
@@ -514,19 +525,29 @@ const loadCodes = async () => {
|
||||
pagination.page_size,
|
||||
{
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{ signal: currentController.signal }
|
||||
)
|
||||
if (currentController.signal.aborted) return
|
||||
if (currentController.signal.aborted || abortController !== currentController) return
|
||||
|
||||
codes.value = response.items
|
||||
pagination.total = response.total
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
if (
|
||||
currentController.signal.aborted ||
|
||||
abortController !== currentController ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.promo.failedToLoad'))
|
||||
console.error('Error loading promo codes:', error)
|
||||
} finally {
|
||||
if (abortController === currentController && !currentController.signal.aborted) {
|
||||
if (abortController === currentController) {
|
||||
loading.value = false
|
||||
abortController = null
|
||||
}
|
||||
@@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
const success = await clipboardCopy(text, t('admin.promo.copied'))
|
||||
if (success) {
|
||||
|
||||
@@ -89,7 +89,15 @@
|
||||
|
||||
<template #table>
|
||||
<div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="proxies"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="id"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #header-select>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -946,6 +954,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'id',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const createPasswordVisible = ref(false)
|
||||
@@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => {
|
||||
toggleVisible(target.checked)
|
||||
}
|
||||
|
||||
const buildProxyQueryFilters = () => ({
|
||||
protocol: filters.protocol || undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
|
||||
const loadProxies = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -1058,11 +1078,12 @@ const loadProxies = async () => {
|
||||
abortController = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, {
|
||||
protocol: filters.protocol || undefined,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
}, { signal: currentAbortController.signal })
|
||||
const response = await adminAPI.proxies.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
buildProxyQueryFilters(),
|
||||
{ signal: currentAbortController.signal }
|
||||
)
|
||||
if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
|
||||
return
|
||||
}
|
||||
@@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createMode.value = 'standard'
|
||||
@@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
|
||||
{
|
||||
protocol: filters.protocol || undefined,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}
|
||||
)
|
||||
result.push(...response.items)
|
||||
@@ -1689,11 +1719,7 @@ const handleExportData = async () => {
|
||||
selectedCount.value > 0
|
||||
? { ids: Array.from(selectedProxyIds.value) }
|
||||
: {
|
||||
filters: {
|
||||
protocol: filters.protocol || undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
filters: buildProxyQueryFilters()
|
||||
}
|
||||
)
|
||||
const timestamp = formatExportTimestamp()
|
||||
|
||||
@@ -47,7 +47,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="codes"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="id"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-code="{ value }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
|
||||
@@ -537,6 +545,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'id',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
@@ -565,6 +577,14 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const buildRedeemQueryFilters = () => ({
|
||||
type: (filters.type || undefined) as RedeemCodeType | undefined,
|
||||
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
|
||||
const loadCodes = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -576,11 +596,7 @@ const loadCodes = async () => {
|
||||
const response = await adminAPI.redeem.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
},
|
||||
buildRedeemQueryFilters(),
|
||||
{
|
||||
signal: currentController.signal
|
||||
}
|
||||
@@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleGenerateCodes = async () => {
|
||||
// 订阅类型必须选择分组
|
||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||
@@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => {
|
||||
|
||||
const handleExportCodes = async () => {
|
||||
try {
|
||||
const blob = await adminAPI.redeem.exportCodes({
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any
|
||||
})
|
||||
const blob = await adminAPI.redeem.exportCodes(buildRedeemQueryFilters())
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
|
||||
@@ -1788,6 +1788,48 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Global Table Preferences -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.site.tablePreferencesTitle') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tablePreferencesDescription') }}
|
||||
</p>
|
||||
<div class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.tableDefaultPageSize') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.table_default_page_size"
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="1"
|
||||
class="input w-40"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tableDefaultPageSizeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.tablePageSizeOptions') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="tablePageSizeOptionsInput"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.site.tablePageSizeOptionsPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tablePageSizeOptionsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Endpoints -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -2520,6 +2562,7 @@ const smtpPasswordManuallyEdited = ref(false)
|
||||
const testEmailAddress = ref('')
|
||||
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
||||
const registrationEmailSuffixWhitelistDraft = ref('')
|
||||
const tablePageSizeOptionsInput = ref('10, 20, 50, 100')
|
||||
|
||||
// Admin API Key 状态
|
||||
const adminApiKeyLoading = ref(true)
|
||||
@@ -2574,6 +2617,10 @@ const betaPolicyForm = reactive({
|
||||
}>
|
||||
})
|
||||
|
||||
const tablePageSizeMin = 5
|
||||
const tablePageSizeMax = 1000
|
||||
const tablePageSizeDefault = 20
|
||||
|
||||
interface DefaultSubscriptionGroupOption {
|
||||
value: number
|
||||
label: string
|
||||
@@ -2613,6 +2660,8 @@ const form = reactive<SettingsForm>({
|
||||
backend_mode_enabled: false,
|
||||
hide_ccs_import_button: 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',
|
||||
table_default_page_size: tablePageSizeDefault,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
|
||||
frontend_url: '',
|
||||
@@ -2836,6 +2885,35 @@ function removeEndpoint(index: number) {
|
||||
form.custom_endpoints.splice(index, 1)
|
||||
}
|
||||
|
||||
function formatTablePageSizeOptions(options: number[]): string {
|
||||
return options.join(', ')
|
||||
}
|
||||
|
||||
function parseTablePageSizeOptionsInput(raw: string): number[] | null {
|
||||
const tokens = raw
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = tokens.map((token) => Number(token))
|
||||
if (parsed.some((value) => !Number.isInteger(value))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const deduped = Array.from(new Set(parsed)).sort((a, b) => a - b)
|
||||
if (
|
||||
deduped.some((value) => value < tablePageSizeMin || value > tablePageSizeMax)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return deduped
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
@@ -2860,6 +2938,9 @@ async function loadSettings() {
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
settings.registration_email_suffix_whitelist
|
||||
)
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(settings.table_page_size_options) ? settings.table_page_size_options : [10, 20, 50, 100]
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
@@ -2903,6 +2984,37 @@ function removeDefaultSubscription(index: number) {
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const normalizedTableDefaultPageSize = Math.floor(Number(form.table_default_page_size))
|
||||
if (
|
||||
!Number.isInteger(normalizedTableDefaultPageSize) ||
|
||||
normalizedTableDefaultPageSize < tablePageSizeMin ||
|
||||
normalizedTableDefaultPageSize > tablePageSizeMax
|
||||
) {
|
||||
appStore.showError(
|
||||
t('admin.settings.site.tableDefaultPageSizeRangeError', {
|
||||
min: tablePageSizeMin,
|
||||
max: tablePageSizeMax
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedTablePageSizeOptions = parseTablePageSizeOptionsInput(
|
||||
tablePageSizeOptionsInput.value
|
||||
)
|
||||
if (!normalizedTablePageSizeOptions) {
|
||||
appStore.showError(
|
||||
t('admin.settings.site.tablePageSizeOptionsFormatError', {
|
||||
min: tablePageSizeMin,
|
||||
max: tablePageSizeMax
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
form.table_default_page_size = normalizedTableDefaultPageSize
|
||||
form.table_page_size_options = normalizedTablePageSizeOptions
|
||||
|
||||
const normalizedDefaultSubscriptions = form.default_subscriptions
|
||||
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
||||
.map((item: DefaultSubscriptionSetting) => ({
|
||||
@@ -2963,6 +3075,8 @@ async function saveSettings() {
|
||||
home_content: form.home_content,
|
||||
backend_mode_enabled: form.backend_mode_enabled,
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
table_default_page_size: form.table_default_page_size,
|
||||
table_page_size_options: form.table_page_size_options,
|
||||
custom_menu_items: form.custom_menu_items,
|
||||
custom_endpoints: form.custom_endpoints,
|
||||
frontend_url: form.frontend_url,
|
||||
@@ -3045,6 +3159,9 @@ async function saveSettings() {
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist
|
||||
)
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(updated.table_page_size_options) ? updated.table_page_size_options : [10, 20, 50, 100]
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
:data="subscriptions"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
|
||||
@@ -100,7 +100,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</UsageFilters>
|
||||
<UsageTable :data="usageLogs" :loading="loading" :columns="visibleColumns" @userClick="handleUserClick" />
|
||||
<UsageTable
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:columns="visibleColumns"
|
||||
:server-side-sort="true"
|
||||
:default-sort-key="'created_at'"
|
||||
:default-sort-order="'desc'"
|
||||
@sort="handleSort"
|
||||
@userClick="handleUserClick"
|
||||
/>
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates()
|
||||
const startDate = ref(defaultRange.start); const endDate = ref(defaultRange.end)
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||
const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 })
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => {
|
||||
if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0)
|
||||
@@ -265,12 +278,31 @@ const onDateRangeChange = (range: { startDate: string; endDate: string; preset:
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const buildUsageListParams = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
exactTotal: boolean
|
||||
): AdminUsageQueryParams => {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
return {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
exact_total: exactTotal,
|
||||
...filters.value,
|
||||
stream: legacyStream === null ? undefined : legacyStream,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
}
|
||||
}
|
||||
|
||||
const loadLogs = async () => {
|
||||
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
|
||||
try {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, exact_total: false, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
|
||||
const res = await adminAPI.usage.list(
|
||||
buildUsageListParams(pagination.page, pagination.page_size, false),
|
||||
{ signal: c.signal }
|
||||
)
|
||||
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
|
||||
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
|
||||
}
|
||||
@@ -412,6 +444,12 @@ const resetFilters = () => {
|
||||
}
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
const getRequestTypeLabel = (log: AdminUsageLog): string => {
|
||||
@@ -443,9 +481,10 @@ const exportToExcel = async () => {
|
||||
]
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers])
|
||||
while (true) {
|
||||
const requestType = filters.value.request_type
|
||||
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, exact_total: true, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
|
||||
const res = await adminUsageAPI.list(
|
||||
buildUsageListParams(p, 100, true),
|
||||
{ signal: c.signal }
|
||||
)
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
const rows = (res.items || []).map((log: AdminUsageLog) => [
|
||||
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
|
||||
|
||||
@@ -235,7 +235,17 @@
|
||||
|
||||
<!-- Users Table -->
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="users" :loading="loading" :actions-count="7">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="users"
|
||||
:loading="loading"
|
||||
:actions-count="7"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
:sort-storage-key="USER_SORT_STORAGE_KEY"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-email="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
@@ -774,6 +784,25 @@ const columns = computed<Column[]>(() =>
|
||||
const users = ref<AdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
|
||||
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
|
||||
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
|
||||
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'created_at'])
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const parsed = JSON.parse(raw) as { key?: string; order?: string }
|
||||
const key = typeof parsed.key === 'string' ? parsed.key : ''
|
||||
if (!sortable.has(key)) return fallback
|
||||
return {
|
||||
sort_by: key,
|
||||
sort_order: parsed.order === 'asc' ? 'asc' : 'desc'
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
const sortState = reactive(loadInitialSortState())
|
||||
|
||||
// Groups data for the groups column
|
||||
const allGroups = ref<AdminGroup[]>([])
|
||||
@@ -1125,7 +1154,9 @@ const loadUsers = async () => {
|
||||
search: searchQuery.value || undefined,
|
||||
group_name: filters.group || undefined,
|
||||
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
|
||||
include_subscriptions: hasVisibleSubscriptionsColumn.value
|
||||
include_subscriptions: hasVisibleSubscriptionsColumn.value,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// Filter helpers
|
||||
const getAttributeDefinitionName = (attrId: number): string => {
|
||||
const def = attributeDefinitions.value.find(d => d.id === attrId)
|
||||
|
||||
@@ -202,7 +202,6 @@
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10]"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:pageSize="emit('update:pageSize', $event)"
|
||||
/>
|
||||
|
||||
@@ -512,7 +512,6 @@ onMounted(async () => {
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50, 100, 200]"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="apiKeys" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="apiKeys"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-key="{ value, row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="code text-xs">
|
||||
@@ -1114,6 +1122,10 @@ const pagination = ref({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = ref({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
// Filter state
|
||||
const filterSearch = ref('')
|
||||
@@ -1277,10 +1289,18 @@ const loadApiKeys = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Build filters
|
||||
const filters: { search?: string; status?: string; group_id?: number | string } = {}
|
||||
const filters: {
|
||||
search?: string
|
||||
status?: string
|
||||
group_id?: number | string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
} = {}
|
||||
if (filterSearch.value) filters.search = filterSearch.value
|
||||
if (filterStatus.value) filters.status = filterStatus.value
|
||||
if (filterGroupId.value !== '') filters.group_id = filterGroupId.value
|
||||
filters.sort_by = sortState.value.sort_by
|
||||
filters.sort_order = sortState.value.sort_order
|
||||
|
||||
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, filters, {
|
||||
signal
|
||||
@@ -1360,6 +1380,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.value.sort_by = key
|
||||
sortState.value.sort_order = order
|
||||
pagination.value.page = 1
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
|
||||
|
||||
@@ -149,7 +149,15 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-api_key="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{
|
||||
row.api_key?.name || '-'
|
||||
@@ -598,6 +606,10 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`
|
||||
@@ -660,6 +672,18 @@ const formatTokens = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
type UsageTableQueryParams = UsageQueryParams & {
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const buildUsageQueryParams = (page: number, pageSize: number): UsageTableQueryParams => ({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters.value,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
})
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
if (abortController) {
|
||||
@@ -670,13 +694,10 @@ const loadUsageLogs = async () => {
|
||||
const { signal } = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const params: UsageQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
...filters.value
|
||||
}
|
||||
|
||||
const response = await usageAPI.query(params, { signal })
|
||||
const response = await usageAPI.query(
|
||||
buildUsageQueryParams(pagination.page, pagination.page_size),
|
||||
{ signal }
|
||||
)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
@@ -758,6 +779,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape CSV value to prevent injection and handle special characters
|
||||
*/
|
||||
@@ -795,12 +823,7 @@ const exportToCSV = async () => {
|
||||
const totalRequests = Math.ceil(pagination.total / pageSize)
|
||||
|
||||
for (let page = 1; page <= totalRequests; page++) {
|
||||
const params: UsageQueryParams = {
|
||||
page: page,
|
||||
page_size: pageSize,
|
||||
...filters.value
|
||||
}
|
||||
const response = await usageAPI.query(params)
|
||||
const response = await usageAPI.query(buildUsageQueryParams(page, pageSize))
|
||||
allLogs.push(...response.items)
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,17 @@ describe('user UsageView tooltip', () => {
|
||||
await setupState.exportToCSV()
|
||||
|
||||
expect(exportedBlob).not.toBeNull()
|
||||
const hasSortedExportQuery = query.mock.calls.some((call) => {
|
||||
const params = call[0] as Record<string, unknown> | undefined
|
||||
const config = call[1]
|
||||
return (
|
||||
params?.page_size === 100 &&
|
||||
params?.sort_by === 'created_at' &&
|
||||
params?.sort_order === 'desc' &&
|
||||
config === undefined
|
||||
)
|
||||
})
|
||||
expect(hasSortedExportQuery).toBe(true)
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(showSuccess).toHaveBeenCalled()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user