Commit fa833f76 authored by erio's avatar erio
Browse files

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
parents d67ecf89 1ef3782d
...@@ -21,7 +21,15 @@ ...@@ -21,7 +21,15 @@
</button> </button>
</div> </div>
<DataTable :columns="columns" :data="items" :loading="loading"> <DataTable
:columns="columns"
:data="items"
:loading="loading"
:server-side-sort="true"
default-sort-key="email"
default-sort-order="asc"
@sort="handleSort"
>
<template #cell-email="{ value }"> <template #cell-email="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
...@@ -62,7 +70,7 @@ ...@@ -62,7 +70,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
...@@ -98,23 +106,54 @@ const pagination = reactive({ ...@@ -98,23 +106,54 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'email',
sort_order: 'asc' as 'asc' | 'desc'
})
const items = ref<AnnouncementUserReadStatus[]>([]) const items = ref<AnnouncementUserReadStatus[]>([])
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'email', label: t('common.email') }, { key: 'email', label: t('common.email'), sortable: true },
{ key: 'username', label: t('admin.users.columns.username') }, { key: 'username', label: t('admin.users.columns.username'), sortable: true },
{ key: 'balance', label: t('common.balance') }, { key: 'balance', label: t('common.balance'), sortable: true },
{ key: 'eligible', label: t('admin.announcements.eligible') }, { key: 'eligible', label: t('admin.announcements.eligible') },
{ key: 'read_at', label: t('admin.announcements.readAt') } { key: 'read_at', label: t('admin.announcements.readAt') }
]) ])
let currentController: AbortController | null = null let currentController: AbortController | null = null
let searchDebounceTimer: number | null = null
function resetDialogState() {
loading.value = false
search.value = ''
items.value = []
pagination.page = 1
pagination.total = 0
pagination.pages = 0
sortState.sort_by = 'email'
sortState.sort_order = 'asc'
}
function cancelPendingLoad(resetState = false) {
if (searchDebounceTimer) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
currentController?.abort()
currentController = null
if (resetState) {
resetDialogState()
}
}
async function load() { async function load() {
if (!props.show || !props.announcementId) return if (!props.show || !props.announcementId) return
if (currentController) currentController.abort() currentController?.abort()
currentController = new AbortController() const requestController = new AbortController()
currentController = requestController
const { signal } = requestController
try { try {
loading.value = true loading.value = true
...@@ -122,20 +161,37 @@ async function load() { ...@@ -122,20 +161,37 @@ async function load() {
props.announcementId, props.announcementId,
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
search.value {
search: search.value,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
},
{ signal }
) )
if (signal.aborted || currentController !== requestController) return
items.value = res.items items.value = res.items
pagination.total = res.total pagination.total = res.total
pagination.pages = res.pages pagination.pages = res.pages
pagination.page = res.page pagination.page = res.page
pagination.page_size = res.page_size pagination.page_size = res.page_size
} catch (error: any) { } catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return if (
signal.aborted ||
currentController !== requestController ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
console.error('Failed to load read status:', error) console.error('Failed to load read status:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus')) appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
} finally { } finally {
loading.value = false if (currentController === requestController) {
loading.value = false
currentController = null
}
} }
} }
...@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) { ...@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
load() load()
} }
let searchDebounceTimer: number | null = null function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
load()
}
function handleSearch() { function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer) if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => { searchDebounceTimer = window.setTimeout(() => {
...@@ -160,13 +222,17 @@ function handleSearch() { ...@@ -160,13 +222,17 @@ function handleSearch() {
} }
function handleClose() { function handleClose() {
cancelPendingLoad(true)
emit('close') emit('close')
} }
watch( watch(
() => props.show, () => props.show,
(v) => { (v) => {
if (!v) return if (!v) {
cancelPendingLoad(true)
return
}
pagination.page = 1 pagination.page = 1
load() load()
} }
...@@ -181,7 +247,7 @@ watch( ...@@ -181,7 +247,7 @@ watch(
} }
) )
onMounted(() => { onUnmounted(() => {
// noop cancelPendingLoad()
}) })
</script> </script>
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 @@ ...@@ -196,7 +196,6 @@
:total="localEntries.length" :total="localEntries.length"
:page="currentPage" :page="currentPage"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10, 20, 50]"
@update:page="currentPage = $event" @update:page="currentPage = $event"
@update:pageSize="handlePageSizeChange" @update:pageSize="handlePageSizeChange"
/> />
......
<template> <template>
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="overflow-auto"> <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 }"> <template #cell-user="{ row }">
<div class="text-sm"> <div class="text-sm">
<button <button
...@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue' ...@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types' import type { AdminUsageLog } from '@/types'
import type { Column } from '@/components/common/types'
interface Props {
data: AdminUsageLog[]
loading?: boolean
columns: Column[]
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
}
defineProps(['data', 'loading', 'columns']) withDefaults(defineProps<Props>(), {
defineEmits(['userClick']) loading: false,
serverSideSort: false,
defaultSortKey: '',
defaultSortOrder: 'asc'
})
defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
}>()
const { t } = useI18n() const { t } = useI18n()
// Tooltip state - cost // Tooltip state - cost
......
...@@ -68,7 +68,7 @@ ...@@ -68,7 +68,7 @@
'is-scrollable': isScrollable '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"> <thead class="table-header bg-gray-50 dark:bg-dark-800">
<tr> <tr>
<th <th
...@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col { ...@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col {
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent); background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
} }
</style> </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' ...@@ -122,7 +122,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import Select from './Select.vue' import Select from './Select.vue'
import { setPersistedPageSize } from '@/composables/usePersistedPageSize' import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
const { t } = useI18n() const { t } = useI18n()
...@@ -141,7 +141,7 @@ interface Emits { ...@@ -141,7 +141,7 @@ interface Emits {
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
pageSizeOptions: () => [10, 20, 50, 100], pageSizeOptions: () => getConfiguredTablePageSizeOptions(),
showPageSizeSelector: true, showPageSizeSelector: true,
showJump: false showJump: false
}) })
...@@ -161,7 +161,14 @@ const toItem = computed(() => { ...@@ -161,7 +161,14 @@ const toItem = computed(() => {
}) })
const pageSizeSelectOptions = 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, value: size,
label: String(size) label: String(size)
})) }))
...@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => { ...@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => {
const handlePageSizeChange = (value: string | number | boolean | null) => { const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') return if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value)
setPersistedPageSize(newPageSize)
emit('update:pageSize', newPageSize) emit('update:pageSize', newPageSize)
} }
......
...@@ -804,11 +804,14 @@ onMounted(() => { ...@@ -804,11 +804,14 @@ onMounted(() => {
opacity: 0; 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) { .sidebar-svg-icon :deep(svg) {
display: block;
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
stroke: currentColor;
fill: none;
} }
</style> </style>
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;')
})
})
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)
})
})
const STORAGE_KEY = 'table-page-size' import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
const DEFAULT_PAGE_SIZE = 20
/** /**
* 从 localStorage 读取/写入 pageSize * 读取当前系统配置的表格默认每页条数。
* 全局共享一个 key,所有表格统一偏好 * 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
*/ */
export function getPersistedPageSize(fallback = DEFAULT_PAGE_SIZE): number { export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
try { return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
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 {
// 静默失败
}
} }
import { ref, reactive, onUnmounted, toRaw } from 'vue' import { ref, reactive, onUnmounted, toRaw } from 'vue'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
import type { BasePaginationResponse, FetchOptions } from '@/types' import type { BasePaginationResponse, FetchOptions } from '@/types'
import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize' import { getPersistedPageSize } from './usePersistedPageSize'
interface PaginationState { interface PaginationState {
page: number page: number
...@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const handlePageSizeChange = (size: number) => { const handlePageSizeChange = (size: number) => {
pagination.page_size = size pagination.page_size = size
pagination.page = 1 pagination.page = 1
setPersistedPageSize(size)
load() load()
} }
......
...@@ -2057,6 +2057,7 @@ export default { ...@@ -2057,6 +2057,7 @@ export default {
rateLimited: 'Rate Limited', rateLimited: 'Rate Limited',
overloaded: 'Overloaded', overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable', tempUnschedulable: 'Temp Unschedulable',
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}', rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}', modelRateLimitedUntil: '{model} rate limited until {time}',
...@@ -4359,6 +4360,15 @@ export default { ...@@ -4359,6 +4360,15 @@ export default {
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
apiBaseUrlHint: apiBaseUrlHint:
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', '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: { customEndpoints: {
title: 'Custom Endpoints', title: 'Custom Endpoints',
description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page', description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page',
......
...@@ -2240,6 +2240,7 @@ export default { ...@@ -2240,6 +2240,7 @@ export default {
rateLimited: '限流中', rateLimited: '限流中',
overloaded: '过载中', overloaded: '过载中',
tempUnschedulable: '临时不可调度', tempUnschedulable: '临时不可调度',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复', rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}', modelRateLimitedUntil: '{model} 限流至 {time}',
...@@ -4520,6 +4521,15 @@ export default { ...@@ -4520,6 +4521,15 @@ export default {
apiBaseUrl: 'API 端点地址', apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com', 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: { customEndpoints: {
title: '自定义端点', title: '自定义端点',
description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制', description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制',
......
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { getPublicSettings } from '@/api/auth'
// Mock API 模块 // Mock API 模块
vi.mock('@/api/admin/system', () => ({ vi.mock('@/api/admin/system', () => ({
...@@ -15,12 +16,14 @@ describe('useAppStore', () => { ...@@ -15,12 +16,14 @@ describe('useAppStore', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.useFakeTimers() vi.useFakeTimers()
localStorage.clear()
// 清除 window.__APP_CONFIG__ // 清除 window.__APP_CONFIG__
delete (window as any).__APP_CONFIG__ delete (window as any).__APP_CONFIG__
}) })
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
localStorage.clear()
}) })
// --- Toast 消息管理 --- // --- Toast 消息管理 ---
...@@ -291,5 +294,43 @@ describe('useAppStore', () => { ...@@ -291,5 +294,43 @@ describe('useAppStore', () => {
expect(store.publicSettingsLoaded).toBe(false) expect(store.publicSettingsLoaded).toBe(false)
expect(store.cachedPublicSettings).toBeNull() 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', () => { ...@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => {
* Apply settings to store state (internal helper to avoid code duplication) * Apply settings to store state (internal helper to avoid code duplication)
*/ */
function applySettings(config: PublicSettings): void { function applySettings(config: PublicSettings): void {
if (typeof window !== 'undefined') {
window.__APP_CONFIG__ = { ...config }
}
cachedPublicSettings.value = config cachedPublicSettings.value = config
siteName.value = config.site_name || 'Sub2API' siteName.value = config.site_name || 'Sub2API'
siteLogo.value = config.site_logo || '' siteLogo.value = config.site_logo || ''
...@@ -327,9 +330,9 @@ export const useAppStore = defineStore('app', () => { ...@@ -327,9 +330,9 @@ export const useAppStore = defineStore('app', () => {
doc_url: docUrl.value, doc_url: docUrl.value,
home_content: '', home_content: '',
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
payment_enabled: false, payment_enabled: false,
table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [], custom_menu_items: [],
custom_endpoints: [], custom_endpoints: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
......
...@@ -16,20 +16,22 @@ ...@@ -16,20 +16,22 @@
@apply min-h-screen; @apply min-h-screen;
} }
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */ /* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */
* { @supports (-moz-appearance:none) {
scrollbar-width: thin; * {
scrollbar-color: transparent transparent; scrollbar-width: thin;
} scrollbar-color: transparent transparent;
}
*:hover, *:hover,
*:focus-within { *:focus-within {
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
} }
.dark *:hover, .dark *:hover,
.dark *:focus-within { .dark *:focus-within {
scrollbar-color: rgba(75, 85, 99, 0.5) transparent; scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
}
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
...@@ -58,36 +60,7 @@ ...@@ -58,36 +60,7 @@
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100; @apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
} }
/*
* 表格滚动容器:始终显示滚动条,不跟随全局悬停策略。
*
* 浏览器兼容性说明:
* - Chrome 121+ 原生支持 scrollbar-color / scrollbar-width。
* 一旦元素匹配了这两个标准属性,::-webkit-scrollbar-* 被完全忽略。
* 全局 * { scrollbar-width: thin } 使所有元素都走标准属性,
* 因此 Chrome 121+ 只看 scrollbar-color。
* - Chrome < 121 不认识标准属性,只看 ::-webkit-scrollbar-*,
* 所以保留 ::-webkit-scrollbar-thumb 作为回退。
* - Firefox 始终只看 scrollbar-color / scrollbar-width。
*/
.table-wrapper {
scrollbar-width: auto;
scrollbar-color: rgba(156, 163, 175, 0.7) transparent;
}
.dark .table-wrapper {
scrollbar-color: rgba(75, 85, 99, 0.7) transparent;
}
/* 旧版 Chrome (< 121) 兼容回退 */
.table-wrapper::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.table-wrapper::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-400/70;
}
.dark .table-wrapper::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-500/70;
}
} }
@layer components { @layer components {
......
...@@ -104,9 +104,9 @@ export interface PublicSettings { ...@@ -104,9 +104,9 @@ export interface PublicSettings {
doc_url: string doc_url: string
home_content: string home_content: string
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
payment_enabled: boolean payment_enabled: boolean
table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
...@@ -1364,6 +1364,8 @@ export interface UsageQueryParams { ...@@ -1364,6 +1364,8 @@ export interface UsageQueryParams {
billing_type?: number | null billing_type?: number | null
start_date?: string start_date?: string
end_date?: string end_date?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
// ==================== Account Usage Statistics ==================== // ==================== Account Usage Statistics ====================
......
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])
})
})
const MIN_TABLE_PAGE_SIZE = 5
const MAX_TABLE_PAGE_SIZE = 1000
export const DEFAULT_TABLE_PAGE_SIZE = 20
export const DEFAULT_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
const sanitizePageSize = (value: unknown): number | null => {
const size = Number(value)
if (!Number.isInteger(size)) return null
if (size < MIN_TABLE_PAGE_SIZE || size > MAX_TABLE_PAGE_SIZE) return null
return size
}
const parsePageSizeForSelection = (value: unknown): number | null => {
const size = Number(value)
if (!Number.isInteger(size)) return null
if (size < MIN_TABLE_PAGE_SIZE) return null
return size
}
const getInjectedAppConfig = () => {
if (typeof window === 'undefined') return null
return window.__APP_CONFIG__ ?? null
}
const getSanitizedConfiguredOptions = (): number[] => {
const configured = getInjectedAppConfig()?.table_page_size_options
if (!Array.isArray(configured)) return []
return Array.from(
new Set(
configured
.map((value) => sanitizePageSize(value))
.filter((value): value is number => value !== null)
)
).sort((a, b) => a - b)
}
const normalizePageSizeToOptions = (value: number, options: number[]): number => {
for (const option of options) {
if (option >= value) {
return option
}
}
return options[options.length - 1]
}
export const getConfiguredTableDefaultPageSize = (): number => {
const configured = sanitizePageSize(getInjectedAppConfig()?.table_default_page_size)
if (configured === null) {
return DEFAULT_TABLE_PAGE_SIZE
}
return configured
}
export const getConfiguredTablePageSizeOptions = (): number[] => {
const unique = getSanitizedConfiguredOptions()
if (unique.length === 0) {
return [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
}
return unique.length > 0 ? unique : [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
}
export const normalizeTablePageSize = (value: unknown): number => {
const normalized = parsePageSizeForSelection(value)
const defaultSize = getConfiguredTableDefaultPageSize()
const options = getConfiguredTablePageSizeOptions()
if (normalized !== null) {
return normalizePageSizeToOptions(normalized, options)
}
return normalizePageSizeToOptions(defaultSize, options)
}
...@@ -148,6 +148,8 @@ ...@@ -148,6 +148,8 @@
:data="accounts" :data="accounts"
:loading="loading" :loading="loading"
row-key="id" row-key="id"
:server-side-sort="true"
@sort="handleSort"
default-sort-key="name" default-sort-key="name"
default-sort-order="asc" default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' ...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings // Sorting settings
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort' const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
type AccountSortOrder = 'asc' | 'desc'
type AccountSortState = {
sort_by: string
sort_order: AccountSortOrder
}
const ACCOUNT_SORTABLE_KEYS = new Set([
'name',
'status',
'schedulable',
'priority',
'rate_multiplier',
'last_used_at',
'expires_at'
])
const loadInitialAccountSortState = (): AccountSortState => {
const fallback: AccountSortState = { sort_by: 'name', sort_order: 'asc' }
try {
const raw = localStorage.getItem(ACCOUNT_SORT_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!ACCOUNT_SORTABLE_KEYS.has(key)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'desc' ? 'desc' : 'asc'
}
} catch {
return fallback
}
}
const sortState = reactive<AccountSortState>(loadInitialAccountSortState())
// Auto refresh settings // Auto refresh settings
const showAutoRefreshDropdown = ref(false) const showAutoRefreshDropdown = ref(false)
...@@ -594,7 +627,16 @@ const { ...@@ -594,7 +627,16 @@ const {
handlePageSizeChange: baseHandlePageSizeChange handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({ } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', privacy_mode: '', group: '', search: '' } initialParams: {
platform: '',
type: '',
status: '',
privacy_mode: '',
group: '',
search: '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
}) })
const { const {
...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => { ...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
baseHandlePageSizeChange(size) baseHandlePageSizeChange(size)
} }
const handleSort = (key: string, order: AccountSortOrder) => {
sortState.sort_by = key
sortState.sort_order = order
const requestParams = params as any
requestParams.sort_by = key
requestParams.sort_order = order
pagination.page = 1
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
load()
}
watch(loading, (isLoading, wasLoading) => { watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) { if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false pendingTodayStatsRefresh.value = false
...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => { ...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
privacy_mode?: string privacy_mode?: string
group?: string group?: string
search?: string search?: string
sort_by?: string
sort_order?: AccountSortOrder
}, },
{ etag: autoRefreshETag.value } { etag: autoRefreshETag.value }
...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => { ...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
} }
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() } const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
const handleDataImported = () => { showImportData.value = false; reload() } const handleDataImported = () => { showImportData.value = false; reload() }
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
const buildAccountQueryFilters = () => ({
platform: params.platform || '',
type: params.type || '',
status: params.status || '',
group: params.group || '',
privacy_mode: params.privacy_mode || '',
search: params.search || '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const accountMatchesCurrentFilters = (account: Account) => { const accountMatchesCurrentFilters = (account: Account) => {
if (params.platform && account.platform !== params.platform) return false const filters = buildAccountQueryFilters()
if (params.type && account.type !== params.type) return false if (filters.platform && account.platform !== filters.platform) return false
if (params.status) { if (filters.type && account.type !== filters.type) return false
if (params.status === 'rate_limited') { if (filters.status) {
if (!account.rate_limit_reset_at) return false const now = Date.now()
const resetAt = new Date(account.rate_limit_reset_at).getTime() const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
} else if (account.status !== params.status) { const tempUnschedUntil = account.temp_unschedulable_until ? new Date(account.temp_unschedulable_until).getTime() : Number.NaN
const isTempUnschedulable = Number.isFinite(tempUnschedUntil) && tempUnschedUntil > now
if (filters.status === 'active') {
if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false
} else if (filters.status === 'rate_limited') {
if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false
} else if (filters.status === 'temp_unschedulable') {
if (account.status !== 'active' || !isTempUnschedulable) return false
} else if (filters.status === 'unschedulable') {
if (account.status !== 'active' || account.schedulable || isRateLimited || isTempUnschedulable) return false
} else if (account.status !== filters.status) {
return false
}
}
if (filters.group) {
const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? []
if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) {
if (groupIds.length > 0) return false
} else if (!groupIds.includes(Number(filters.group))) {
return false
}
}
const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
if (filters.privacy_mode) {
if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) {
if (privacyMode.trim() !== '') return false
} else if (privacyMode !== filters.privacy_mode) {
return false return false
} }
} }
const search = String(params.search || '').trim().toLowerCase() const search = String(filters.search || '').trim().toLowerCase()
if (search && !account.name.toLowerCase().includes(search)) return false if (search && !account.name.toLowerCase().includes(search)) return false
return true return true
} }
...@@ -1181,12 +1277,7 @@ const handleExportData = async () => { ...@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
? { ids: selIds.value, includeProxies: includeProxyOnExport.value } ? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: { : {
includeProxies: includeProxyOnExport.value, includeProxies: includeProxyOnExport.value,
filters: { filters: buildAccountQueryFilters()
platform: params.platform,
type: params.type,
status: params.status,
search: params.search
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment