feat(keys): add column settings

This commit is contained in:
Bestony
2026-06-27 15:14:13 +08:00
parent c275422251
commit b244f850e7
4 changed files with 424 additions and 15 deletions
+2
View File
@@ -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',
+2
View File
@@ -716,6 +716,8 @@ export default {
},
allGroups: '全部分组',
allStatus: '全部状态',
columnSettings: '列设置',
columnAlwaysVisible: '该列固定显示,不可隐藏',
createKey: '创建密钥',
editKey: '编辑密钥',
deleteKey: '删除密钥',
+114 -15
View File
@@ -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')
})
})