mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(keys): add column settings
This commit is contained in:
@@ -717,6 +717,8 @@ export default {
|
||||
},
|
||||
allGroups: 'All Groups',
|
||||
allStatus: 'All Status',
|
||||
columnSettings: 'Column Settings',
|
||||
columnAlwaysVisible: 'This column is always visible',
|
||||
createKey: 'Create API Key',
|
||||
editKey: 'Edit API Key',
|
||||
deleteKey: 'Delete API Key',
|
||||
|
||||
@@ -716,6 +716,8 @@ export default {
|
||||
},
|
||||
allGroups: '全部分组',
|
||||
allStatus: '全部状态',
|
||||
columnSettings: '列设置',
|
||||
columnAlwaysVisible: '该列固定显示,不可隐藏',
|
||||
createKey: '创建密钥',
|
||||
editKey: '编辑密钥',
|
||||
deleteKey: '删除密钥',
|
||||
|
||||
@@ -33,19 +33,51 @@
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadApiKeys"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="keys-create-btn">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('keys.createKey') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="loadApiKeys"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('keys.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('keys.columnSettings') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="keys-create-btn">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('keys.createKey') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
@@ -1045,7 +1077,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
@@ -1099,7 +1131,7 @@ const appStore = useAppStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('common.name'), sortable: true },
|
||||
{ key: 'key', label: t('keys.apiKey'), sortable: false },
|
||||
{ key: 'group', label: t('keys.group'), sortable: false },
|
||||
@@ -1112,6 +1144,67 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'actions', label: t('common.actions'), sortable: false }
|
||||
])
|
||||
|
||||
const ALWAYS_VISIBLE_COLUMNS = new Set(['name', 'actions'])
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at']
|
||||
const HIDDEN_COLUMNS_KEY = 'api-key-hidden-columns'
|
||||
const COLUMN_SETTINGS_VERSION_KEY = 'api-key-column-settings-version'
|
||||
const COLUMN_SETTINGS_VERSION = 1
|
||||
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key))
|
||||
)
|
||||
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key table columns:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSavedColumns = () => {
|
||||
hiddenColumns.clear()
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as string[]
|
||||
const validColumnKeys = new Set(allColumns.value.map((col) => col.key))
|
||||
parsed
|
||||
.filter((key) =>
|
||||
typeof key === 'string' &&
|
||||
validColumnKeys.has(key) &&
|
||||
!ALWAYS_VISIBLE_COLUMNS.has(key)
|
||||
)
|
||||
.forEach((key) => hiddenColumns.add(key))
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
||||
}
|
||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
} catch (error) {
|
||||
console.error('Failed to load API key table columns:', error)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
if (ALWAYS_VISIBLE_COLUMNS.has(key)) return
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
}
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
const columns = computed<Column[]>(() =>
|
||||
allColumns.value.filter((col) => ALWAYS_VISIBLE_COLUMNS.has(col.key) || !hiddenColumns.has(col.key))
|
||||
)
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -1144,12 +1237,14 @@ const showResetQuotaDialog = ref(false)
|
||||
const showResetRateLimitDialog = ref(false)
|
||||
const showUseKeyModal = ref(false)
|
||||
const showCcsClientSelect = ref(false)
|
||||
const showColumnDropdown = ref(false)
|
||||
const pendingCcsRow = ref<ApiKey | null>(null)
|
||||
const selectedKey = ref<ApiKey | null>(null)
|
||||
const copiedKeyId = ref<number | null>(null)
|
||||
const groupSelectorKeyId = ref<number | null>(null)
|
||||
const publicSettings = ref<PublicSettings | null>(null)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
const dropdownPosition = ref<{ top?: number; bottom?: number; left: number } | null>(null)
|
||||
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
||||
let abortController: AbortController | null = null
|
||||
@@ -1478,6 +1573,9 @@ const closeGroupSelector = (event: MouseEvent) => {
|
||||
groupSelectorKeyId.value = null
|
||||
dropdownPosition.value = null
|
||||
}
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (key: ApiKey) => {
|
||||
@@ -1775,6 +1873,7 @@ function formatResetTime(resetAt: string | null): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSavedColumns()
|
||||
loadApiKeys()
|
||||
loadGroups()
|
||||
loadUserGroupRates()
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { ApiKey } from '@/types'
|
||||
import KeysView from '../KeysView.vue'
|
||||
|
||||
const {
|
||||
listKeys,
|
||||
getPublicSettings,
|
||||
getDashboardApiKeysUsage,
|
||||
getAvailableGroups,
|
||||
getUserGroupRates,
|
||||
showError,
|
||||
showSuccess,
|
||||
copyToClipboard,
|
||||
isCurrentStep,
|
||||
nextStep,
|
||||
} = vi.hoisted(() => ({
|
||||
listKeys: vi.fn(),
|
||||
getPublicSettings: vi.fn(),
|
||||
getDashboardApiKeysUsage: vi.fn(),
|
||||
getAvailableGroups: vi.fn(),
|
||||
getUserGroupRates: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
isCurrentStep: vi.fn(),
|
||||
nextStep: vi.fn(),
|
||||
}))
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'common.actions': 'Actions',
|
||||
'common.name': 'Name',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.status': 'Status',
|
||||
'keys.apiKey': 'API Key',
|
||||
'keys.allGroups': 'All Groups',
|
||||
'keys.allStatus': 'All Status',
|
||||
'keys.columnSettings': 'Column Settings',
|
||||
'keys.createKey': 'Create API Key',
|
||||
'keys.created': 'Created',
|
||||
'keys.expiresAt': 'Expires',
|
||||
'keys.group': 'Group',
|
||||
'keys.lastUsedAt': 'Last Used',
|
||||
'keys.rateLimitColumn': 'Rate Limit',
|
||||
'keys.searchPlaceholder': 'Search name or key...',
|
||||
'keys.status.active': 'Active',
|
||||
'keys.status.expired': 'Expired',
|
||||
'keys.status.inactive': 'Inactive',
|
||||
'keys.status.quota_exhausted': 'Quota exhausted',
|
||||
'keys.usage': 'Usage',
|
||||
}
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
keysAPI: {
|
||||
list: listKeys,
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toggleStatus: vi.fn(),
|
||||
},
|
||||
authAPI: {
|
||||
getPublicSettings,
|
||||
},
|
||||
usageAPI: {
|
||||
getDashboardApiKeysUsage,
|
||||
},
|
||||
userGroupsAPI: {
|
||||
getAvailable: getAvailableGroups,
|
||||
getUserGroupRates,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/onboarding', () => ({
|
||||
useOnboardingStore: () => ({
|
||||
isCurrentStep,
|
||||
nextStep,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => messages[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createApiKey = (): ApiKey => ({
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
key: 'sk-test-key',
|
||||
name: 'test-key',
|
||||
group_id: null,
|
||||
status: 'active',
|
||||
ip_whitelist: [],
|
||||
ip_blacklist: [],
|
||||
last_used_at: null,
|
||||
quota: 0,
|
||||
quota_used: 0,
|
||||
expires_at: null,
|
||||
created_at: '2026-06-27T00:00:00Z',
|
||||
updated_at: '2026-06-27T00:00:00Z',
|
||||
rate_limit_5h: 0,
|
||||
rate_limit_1d: 0,
|
||||
rate_limit_7d: 0,
|
||||
usage_5h: 0,
|
||||
usage_1d: 0,
|
||||
usage_7d: 0,
|
||||
window_5h_start: null,
|
||||
window_1d_start: null,
|
||||
window_7d_start: null,
|
||||
reset_5h_at: null,
|
||||
reset_1d_at: null,
|
||||
reset_7d_at: null,
|
||||
})
|
||||
|
||||
const AppLayoutStub = {
|
||||
template: '<div><slot /></div>',
|
||||
}
|
||||
|
||||
const TablePageLayoutStub = {
|
||||
template: `
|
||||
<div>
|
||||
<slot name="filters" />
|
||||
<slot name="actions" />
|
||||
<slot name="table" />
|
||||
<slot name="pagination" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
const DataTableStub = {
|
||||
props: ['columns', 'data'],
|
||||
emits: ['sort'],
|
||||
template: `
|
||||
<div>
|
||||
<div data-test="columns">{{ columns.map((col) => col.key).join(',') }}</div>
|
||||
<div v-for="row in data" :key="row.id">
|
||||
<slot name="cell-name" :value="row.name" :row="row" />
|
||||
</div>
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
const SelectStub = {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"></select>',
|
||||
}
|
||||
|
||||
const SearchInputStub = {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue', 'search'],
|
||||
template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
}
|
||||
|
||||
const IconStub = {
|
||||
props: ['name'],
|
||||
template: '<span data-test="icon">{{ name }}</span>',
|
||||
}
|
||||
|
||||
const mountView = async () => {
|
||||
const wrapper = mount(KeysView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
DataTable: DataTableStub,
|
||||
Pagination: true,
|
||||
BaseDialog: true,
|
||||
ConfirmDialog: true,
|
||||
EmptyState: true,
|
||||
Select: SelectStub,
|
||||
SearchInput: SearchInputStub,
|
||||
Icon: IconStub,
|
||||
UseKeyModal: true,
|
||||
EndpointPopover: true,
|
||||
GroupBadge: true,
|
||||
GroupOptionItem: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const visibleColumnKeys = (wrapper: VueWrapper) =>
|
||||
wrapper.get('[data-test="columns"]').text().split(',').filter(Boolean)
|
||||
|
||||
const getButtonByText = (wrapper: VueWrapper, text: string) => {
|
||||
const button = wrapper.findAll('button').find((item) => item.text().includes(text))
|
||||
if (!button) {
|
||||
throw new Error(`Button not found: ${text}`)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
describe('user KeysView column settings', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
listKeys.mockReset()
|
||||
getPublicSettings.mockReset()
|
||||
getDashboardApiKeysUsage.mockReset()
|
||||
getAvailableGroups.mockReset()
|
||||
getUserGroupRates.mockReset()
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
copyToClipboard.mockReset()
|
||||
isCurrentStep.mockReset()
|
||||
nextStep.mockReset()
|
||||
|
||||
listKeys.mockResolvedValue({
|
||||
items: [createApiKey()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
})
|
||||
getPublicSettings.mockResolvedValue({})
|
||||
getDashboardApiKeysUsage.mockResolvedValue({ stats: {} })
|
||||
getAvailableGroups.mockResolvedValue([])
|
||||
getUserGroupRates.mockResolvedValue({})
|
||||
isCurrentStep.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('uses the default API key columns with low-frequency columns hidden', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(visibleColumnKeys(wrapper)).toEqual([
|
||||
'name',
|
||||
'key',
|
||||
'group',
|
||||
'usage',
|
||||
'expires_at',
|
||||
'status',
|
||||
'created_at',
|
||||
'actions',
|
||||
])
|
||||
expect(visibleColumnKeys(wrapper)).not.toContain('rate_limit')
|
||||
expect(visibleColumnKeys(wrapper)).not.toContain('last_used_at')
|
||||
})
|
||||
|
||||
it('shows a hidden column when toggled and persists the preference', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
await wrapper.get('button[title="Column Settings"]').trigger('click')
|
||||
await getButtonByText(wrapper, 'Rate Limit').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(visibleColumnKeys(wrapper)).toContain('rate_limit')
|
||||
expect(localStorage.getItem('api-key-hidden-columns')).toBe(JSON.stringify(['last_used_at']))
|
||||
expect(localStorage.getItem('api-key-column-settings-version')).toBe('1')
|
||||
})
|
||||
|
||||
it('restores column preferences from localStorage on mount', async () => {
|
||||
localStorage.setItem('api-key-hidden-columns', JSON.stringify(['group', 'created_at']))
|
||||
localStorage.setItem('api-key-column-settings-version', '1')
|
||||
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(visibleColumnKeys(wrapper)).toEqual([
|
||||
'name',
|
||||
'key',
|
||||
'usage',
|
||||
'rate_limit',
|
||||
'expires_at',
|
||||
'status',
|
||||
'last_used_at',
|
||||
'actions',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not include always-visible columns in the toggleable menu', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
await wrapper.get('button[title="Column Settings"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const columnMenuText = wrapper.text()
|
||||
expect(columnMenuText).toContain('API Key')
|
||||
expect(columnMenuText).toContain('Rate Limit')
|
||||
expect(columnMenuText).not.toContain('Name')
|
||||
expect(columnMenuText).not.toContain('Actions')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user