feat(admin): add group column settings

This commit is contained in:
Bestony@Homelab
2026-07-02 15:04:36 +08:00
parent 7dc7cfce1d
commit 5436d0bf10
4 changed files with 455 additions and 3 deletions
+1
View File
@@ -2132,6 +2132,7 @@ export default {
editGroup: 'Edit Group',
deleteGroup: 'Delete Group',
sortOrder: 'Sort',
columnSettings: 'Column Settings',
sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first',
sortOrderUpdated: 'Sort order updated',
failedToUpdateSortOrder: 'Failed to update sort order',
+1
View File
@@ -2185,6 +2185,7 @@ export default {
editGroup: '编辑分组',
deleteGroup: '删除分组',
sortOrder: '排序',
columnSettings: '列设置',
sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
sortOrderUpdated: '排序已更新',
failedToUpdateSortOrder: '更新排序失败',
+125 -3
View File
@@ -60,6 +60,38 @@
:class="loading ? 'animate-spin' : ''"
/>
</button>
<div class="relative" ref="columnDropdownRef">
<button
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary"
:title="t('admin.groups.columnSettings')"
>
<Icon name="grid" size="md" class="mr-2" />
<span class="hidden md:inline">{{
t("admin.groups.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="openSortModal"
class="btn btn-secondary"
@@ -3095,7 +3127,10 @@ const { t } = useI18n();
const appStore = useAppStore();
const onboardingStore = useOnboardingStore();
const columns = computed<Column[]>(() => [
const ALWAYS_VISIBLE_COLUMNS = new Set(["name", "actions"]);
const HIDDEN_COLUMNS_KEY = "group-hidden-columns";
const allColumns = computed<Column[]>(() => [
{ key: "name", label: t("admin.groups.columns.name"), sortable: true },
{
key: "platform",
@@ -3132,6 +3167,77 @@ const columns = computed<Column[]>(() => [
{ key: "actions", label: t("admin.groups.columns.actions"), sortable: false },
]);
const toggleableColumns = computed(() =>
allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key)),
);
const hiddenColumns = reactive<Set<string>>(new Set());
const showColumnDropdown = ref(false);
const columnDropdownRef = ref<HTMLElement | null>(null);
const getValidHiddenColumnKeys = () =>
new Set(toggleableColumns.value.map((col) => col.key));
const loadSavedColumns = () => {
hiddenColumns.clear();
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY);
if (!saved) return;
const parsed = JSON.parse(saved);
if (!Array.isArray(parsed)) return;
const validKeys = getValidHiddenColumnKeys();
parsed
.filter((key): key is string => typeof key === "string" && validKeys.has(key))
.forEach((key) => hiddenColumns.add(key));
} catch (error) {
console.error("Failed to load group column settings:", error);
}
};
const saveColumnsToStorage = () => {
try {
const validKeys = getValidHiddenColumnKeys();
const keys = [...hiddenColumns].filter((key) => validKeys.has(key));
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify(keys));
} catch (error) {
console.error("Failed to save group column settings:", error);
}
};
const isColumnVisible = (key: string) => !hiddenColumns.has(key);
const hasVisibleUsageColumn = computed(() => isColumnVisible("usage"));
const hasVisibleCapacityColumn = computed(() => isColumnVisible("capacity"));
const toggleColumn = (key: string) => {
const validKeys = getValidHiddenColumnKeys();
if (!validKeys.has(key)) return;
const wasHidden = hiddenColumns.has(key);
if (wasHidden) {
hiddenColumns.delete(key);
} else {
hiddenColumns.add(key);
}
saveColumnsToStorage();
if (wasHidden && key === "usage") {
loadUsageSummary();
}
if (wasHidden && key === "capacity") {
loadCapacitySummary();
}
};
const columns = computed<Column[]>(() =>
allColumns.value.filter(
(col) => ALWAYS_VISIBLE_COLUMNS.has(col.key) || !hiddenColumns.has(col.key),
),
);
if (typeof window !== "undefined") {
loadSavedColumns();
}
// Filter options
const statusOptions = computed(() => [
{ value: "", label: t("admin.groups.allStatus") },
@@ -3808,8 +3914,14 @@ const loadGroups = async () => {
groups.value = response.items;
pagination.total = response.total;
pagination.pages = response.pages;
loadUsageSummary();
loadCapacitySummary();
if (hasVisibleUsageColumn.value) {
loadUsageSummary();
} else {
usageLoading.value = false;
}
if (hasVisibleCapacityColumn.value) {
loadCapacitySummary();
}
} catch (error: any) {
if (
signal.aborted ||
@@ -3834,6 +3946,10 @@ const formatCost = (cost: number): string => {
};
const loadUsageSummary = async () => {
if (!hasVisibleUsageColumn.value) {
usageLoading.value = false;
return;
}
usageLoading.value = true;
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -3854,6 +3970,9 @@ const loadUsageSummary = async () => {
};
const loadCapacitySummary = async () => {
if (!hasVisibleCapacityColumn.value) {
return;
}
try {
const data = await adminAPI.groups.getCapacitySummary();
const map = new Map<
@@ -4305,6 +4424,9 @@ const handleClickOutside = (event: MouseEvent) => {
showAccountDropdown.value[key] = false;
});
}
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
showColumnDropdown.value = false;
}
};
// 打开排序弹窗
@@ -0,0 +1,328 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import type { AdminGroup } from '@/types'
import GroupsView from '../GroupsView.vue'
const {
listGroups,
getAllGroups,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
listAccounts,
showError,
showSuccess,
isCurrentStep,
nextStep,
} = vi.hoisted(() => ({
listGroups: vi.fn(),
getAllGroups: vi.fn(),
getModelsListCandidates: vi.fn(),
getUsageSummary: vi.fn(),
getCapacitySummary: vi.fn(),
listAccounts: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
isCurrentStep: vi.fn(),
nextStep: vi.fn(),
}))
const messages: Record<string, string> = {
'admin.groups.columnSettings': 'Column Settings',
'admin.groups.columns.name': 'Name',
'admin.groups.columns.platform': 'Platform',
'admin.groups.columns.billingType': 'Billing Type',
'admin.groups.columns.rateMultiplier': 'Rate Multiplier',
'admin.groups.columns.type': 'Type',
'admin.groups.columns.accounts': 'Accounts',
'admin.groups.columns.capacity': 'Capacity',
'admin.groups.columns.usage': 'Usage',
'admin.groups.columns.status': 'Status',
'admin.groups.columns.actions': 'Actions',
}
vi.mock('@/api/admin', () => ({
adminAPI: {
groups: {
list: listGroups,
getAll: getAllGroups,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
updateSortOrder: vi.fn(),
},
accounts: {
list: listAccounts,
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess,
}),
}))
vi.mock('@/stores/onboarding', () => ({
useOnboardingStore: () => ({
isCurrentStep,
nextStep,
}),
}))
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 createGroup = (overrides: Partial<AdminGroup> = {}): AdminGroup => ({
id: 1,
name: 'Core Anthropic',
description: null,
platform: 'anthropic',
rate_multiplier: 1,
rpm_limit: 0,
is_exclusive: false,
status: 'active',
subscription_type: 'standard',
daily_limit_usd: null,
weekly_limit_usd: null,
monthly_limit_usd: null,
allow_image_generation: false,
image_rate_independent: false,
image_rate_multiplier: 1,
image_price_1k: null,
image_price_2k: null,
image_price_4k: null,
claude_code_only: false,
fallback_group_id: null,
fallback_group_id_on_invalid_request: null,
allow_messages_dispatch: false,
default_mapped_model: '',
messages_dispatch_model_config: undefined,
require_oauth_only: false,
require_privacy_set: false,
created_at: '2026-07-01T00:00:00Z',
updated_at: '2026-07-01T00:00:00Z',
model_routing: null,
model_routing_enabled: false,
mcp_xml_inject: true,
supported_model_scopes: [],
account_count: 3,
active_account_count: 2,
rate_limited_account_count: 1,
models_list_config: undefined,
sort_order: 10,
...overrides,
})
const AppLayoutStub = {
template: '<div><slot /></div>',
}
const TablePageLayoutStub = {
template: `
<div>
<slot name="filters" />
<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 data-test="rows">{{ data.map((row) => row.name).join(',') }}</div>
</div>
`,
}
const SelectStub = {
props: ['modelValue', 'options', 'placeholder'],
emits: ['update:modelValue', 'change'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value); $emit('change')"
>
<option v-for="option in options" :key="String(option.value)" :value="option.value">
{{ option.label }}
</option>
</select>
`,
}
const BaseDialogStub = {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
}
const IconStub = {
props: ['name'],
template: '<span data-test="icon">{{ name }}</span>',
}
const mountView = async () => {
const wrapper = mount(GroupsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
Pagination: true,
BaseDialog: BaseDialogStub,
ConfirmDialog: true,
EmptyState: true,
Select: SelectStub,
PlatformIcon: true,
Icon: IconStub,
GroupCapacityBadge: true,
GroupRateMultipliersModal: true,
GroupRPMOverridesModal: true,
VueDraggable: { template: '<div><slot /></div>' },
},
},
})
await flushPromises()
return wrapper
}
const columnKeys = (wrapper: ReturnType<typeof mount>) =>
wrapper.get('[data-test="columns"]').text().split(',').filter(Boolean)
const openColumnSettings = async (wrapper: ReturnType<typeof mount>) => {
await wrapper.get('button[title="Column Settings"]').trigger('click')
}
const clickColumnToggle = async (wrapper: ReturnType<typeof mount>, label: string) => {
const button = wrapper
.findAll('button')
.find((item) => item.text().includes(label))
expect(button, `column toggle ${label}`).toBeTruthy()
await button!.trigger('click')
await flushPromises()
}
describe('admin GroupsView column settings', () => {
beforeEach(() => {
localStorage.clear()
listGroups.mockReset()
getAllGroups.mockReset()
getModelsListCandidates.mockReset()
getUsageSummary.mockReset()
getCapacitySummary.mockReset()
listAccounts.mockReset()
showError.mockReset()
showSuccess.mockReset()
isCurrentStep.mockReset()
nextStep.mockReset()
listGroups.mockResolvedValue({
items: [createGroup()],
total: 1,
page: 1,
page_size: 20,
pages: 1,
})
getAllGroups.mockResolvedValue([])
getModelsListCandidates.mockResolvedValue([])
getUsageSummary.mockResolvedValue([])
getCapacitySummary.mockResolvedValue([])
listAccounts.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
isCurrentStep.mockReturnValue(false)
})
afterEach(() => {
localStorage.clear()
})
it('renders all group columns by default in the current order', async () => {
const wrapper = await mountView()
expect(columnKeys(wrapper)).toEqual([
'name',
'platform',
'billing_type',
'rate_multiplier',
'is_exclusive',
'account_count',
'capacity',
'usage',
'status',
'actions',
])
})
it('applies saved hidden columns on mount and ignores unknown keys', async () => {
localStorage.setItem(
'group-hidden-columns',
JSON.stringify(['usage', 'capacity', 'removed_column', 'name', 'actions']),
)
const wrapper = await mountView()
expect(columnKeys(wrapper)).toEqual([
'name',
'platform',
'billing_type',
'rate_multiplier',
'is_exclusive',
'account_count',
'status',
'actions',
])
})
it('toggles a column and persists hidden column keys', async () => {
const wrapper = await mountView()
await openColumnSettings(wrapper)
await clickColumnToggle(wrapper, 'Usage')
expect(columnKeys(wrapper)).toEqual([
'name',
'platform',
'billing_type',
'rate_multiplier',
'is_exclusive',
'account_count',
'capacity',
'status',
'actions',
])
expect(localStorage.getItem('group-hidden-columns')).toBe(JSON.stringify(['usage']))
})
it('skips hidden usage and capacity fetches until those columns are shown', async () => {
localStorage.setItem('group-hidden-columns', JSON.stringify(['usage', 'capacity']))
const wrapper = await mountView()
expect(getUsageSummary).not.toHaveBeenCalled()
expect(getCapacitySummary).not.toHaveBeenCalled()
await openColumnSettings(wrapper)
await clickColumnToggle(wrapper, 'Usage')
expect(getUsageSummary).toHaveBeenCalledTimes(1)
expect(getCapacitySummary).not.toHaveBeenCalled()
await clickColumnToggle(wrapper, 'Capacity')
expect(getUsageSummary).toHaveBeenCalledTimes(1)
expect(getCapacitySummary).toHaveBeenCalledTimes(1)
})
})