mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(admin): add group column settings
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -2185,6 +2185,7 @@ export default {
|
||||
editGroup: '编辑分组',
|
||||
deleteGroup: '删除分组',
|
||||
sortOrder: '排序',
|
||||
columnSettings: '列设置',
|
||||
sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
|
||||
sortOrderUpdated: '排序已更新',
|
||||
failedToUpdateSortOrder: '更新排序失败',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user