Merge pull request #4434 from yan9651688/feat/group-one-click-copy

feat(group): add safe one-click duplication
This commit is contained in:
Wesley Liddick
2026-07-16 19:33:18 +08:00
committed by GitHub
32 changed files with 1797 additions and 37 deletions
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import { duplicate } from '@/api/admin/groups'
describe('admin group duplicate API', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
post.mockReset()
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)', status: 'inactive' } })
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('sends a stable idempotency key with the duplicate request', async () => {
const group = await duplicate(42)
expect(post).toHaveBeenCalledWith('/admin/groups/42/duplicate', undefined, {
headers: {
'Idempotency-Key': 'group-duplicate-7-42-11111111-1111-4111-8111-111111111111'
}
})
expect(group).toEqual({ id: 43, name: 'primary (Copy)', status: 'inactive' })
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after an ambiguous failed request', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(99)).rejects.toThrow('network timeout')
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
await duplicate(99)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/groups')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
it('does not reuse an operation key across administrators for the same group', async () => {
post.mockRejectedValueOnce(new Error('first admin timeout'))
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
const firstAdminHeaders = post.mock.calls[0][2].headers
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'22222222-2222-4222-8222-222222222222'
)
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
await duplicate(55)
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
expect(post.mock.calls[1][2].headers).toEqual({
'Idempotency-Key': 'group-duplicate-8-55-22222222-2222-4222-8222-222222222222'
})
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:7:55')).toBe(
firstAdminHeaders['Idempotency-Key']
)
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:8:55')).toBeNull()
})
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
localStorage.setItem('auth_user', '{invalid json')
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(66)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'33333333-3333-4333-8333-333333333333'
)
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
await duplicate(66)
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
+81
View File
@@ -114,6 +114,86 @@ export async function create(groupData: CreateGroupRequest): Promise<AdminGroup>
return data
}
/**
* Duplicate a group on the server so configuration that is not present in the
* list response is preserved. Keep the operation key after ambiguous failures
* so a retry replays the original operation instead of creating another group.
*/
const duplicateOperationKeys = new Map<string, string>()
interface DuplicateOperationScope {
adminID: string
key: string
}
function getCurrentAdminID(): string | null {
try {
const rawUser = globalThis.localStorage?.getItem('auth_user')
if (!rawUser) return null
const user: unknown = JSON.parse(rawUser)
if (typeof user !== 'object' || user === null) return null
const id = (user as { id?: unknown }).id
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
return String(id)
} catch {
return null
}
}
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
const adminID = getCurrentAdminID()
if (!adminID) return null
return {
adminID,
key: `sub2api:admin:group-duplicate:${adminID}:${id}`
}
}
function getStoredDuplicateOperationKey(storageKey: string): string | null {
try {
return globalThis.sessionStorage?.getItem(storageKey) ?? null
} catch {
return null
}
}
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
try {
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
else globalThis.sessionStorage?.removeItem(storageKey)
} catch {
// In-memory retry protection still works when browser storage is unavailable.
}
}
export async function duplicate(id: number): Promise<AdminGroup> {
const scope = duplicateOperationScope(id)
let idempotencyKey = scope
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
: null
if (!idempotencyKey) {
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
}
if (scope) {
duplicateOperationKeys.set(scope.key, idempotencyKey)
storeDuplicateOperationKey(scope.key, idempotencyKey)
}
const { data } = await apiClient.post<AdminGroup>(`/admin/groups/${id}/duplicate`, undefined, {
headers: { 'Idempotency-Key': idempotencyKey }
})
if (scope) {
duplicateOperationKeys.delete(scope.key)
storeDuplicateOperationKey(scope.key, null)
}
return data
}
/**
* Update group
* @param id - Group ID
@@ -337,6 +417,7 @@ export const groupsAPI = {
getById,
getModelsListCandidates,
create,
duplicate,
update,
delete: deleteGroup,
toggleStatus,
@@ -743,6 +743,10 @@ export default {
createGroup: 'Create Group',
editGroup: 'Edit Group',
deleteGroup: 'Delete Group',
duplicate: 'Duplicate',
duplicating: 'Duplicating',
duplicateSuccess: 'Group duplicated as "{name}" and disabled. Review its configuration before enabling it.',
duplicateFailed: 'Failed to duplicate group',
sortOrder: 'Sort',
columnSettings: 'Column Settings',
sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first',
@@ -740,6 +740,10 @@ export default {
createGroup: '创建分组',
editGroup: '编辑分组',
deleteGroup: '删除分组',
duplicate: '复制',
duplicating: '复制中',
duplicateSuccess: '分组已复制为「{name}」,已默认停用,请确认配置后再启用',
duplicateFailed: '复制分组失败',
sortOrder: '排序',
columnSettings: '列设置',
sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
+41
View File
@@ -366,6 +366,26 @@
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t("common.edit") }}</span>
</button>
<button
data-testid="group-duplicate"
:title="
duplicatingGroupIds.has(row.id)
? t('admin.groups.duplicating')
: t('admin.groups.duplicate')
"
:disabled="duplicatingGroupIds.has(row.id)"
@click="handleDuplicate(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="copy" size="sm" />
<span class="text-xs">
{{
duplicatingGroupIds.has(row.id)
? t("admin.groups.duplicating")
: t("admin.groups.duplicate")
}}
</span>
</button>
<button
@click="handleRateMultipliers(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
@@ -3583,6 +3603,7 @@ import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesMo
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
import { extractApiErrorMessage } from "@/utils/apiError";
import { useKeyedDebouncedSearch } from "@/composables/useKeyedDebouncedSearch";
import { getPersistedPageSize } from "@/composables/usePersistedPageSize";
import {
@@ -3967,6 +3988,7 @@ const submitting = ref(false);
const sortSubmitting = ref(false);
const editingGroup = ref<AdminGroup | null>(null);
const deletingGroup = ref<AdminGroup | null>(null);
const duplicatingGroupIds = reactive(new Set<number>());
const showRateMultipliersModal = ref(false);
const rateMultipliersGroup = ref<AdminGroup | null>(null);
const showRPMOverridesModal = ref(false);
@@ -5126,6 +5148,25 @@ const handleRPMOverrides = (group: AdminGroup) => {
showRPMOverridesModal.value = true;
};
const handleDuplicate = async (group: AdminGroup) => {
if (duplicatingGroupIds.has(group.id)) return;
duplicatingGroupIds.add(group.id);
try {
const duplicate = await adminAPI.groups.duplicate(group.id);
appStore.showSuccess(
t("admin.groups.duplicateSuccess", { name: duplicate.name }),
);
await loadGroups();
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.groups.duplicateFailed")),
);
} finally {
duplicatingGroupIds.delete(group.id);
}
};
const handleDelete = (group: AdminGroup) => {
deletingGroup.value = group;
showDeleteDialog.value = true;
@@ -0,0 +1,268 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AdminGroup } from '@/types'
import GroupsView from '@/views/admin/GroupsView.vue'
const {
listGroups,
duplicateGroup,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
showSuccess,
showError
} = vi.hoisted(() => ({
listGroups: vi.fn(),
duplicateGroup: vi.fn(),
getModelsListCandidates: vi.fn(),
getUsageSummary: vi.fn(),
getCapacitySummary: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
groups: {
list: listGroups,
duplicate: duplicateGroup,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
updateSortOrder: vi.fn()
},
accounts: {
list: vi.fn(),
getById: vi.fn()
}
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess, showError })
}))
vi.mock('@/stores/onboarding', () => ({
useOnboardingStore: () => ({
isCurrentStep: vi.fn(() => false),
nextStep: vi.fn()
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
const sourceGroup: AdminGroup = {
id: 42,
name: 'Primary',
description: null,
platform: 'openai',
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,
allow_batch_image_generation: false,
image_rate_independent: false,
image_rate_multiplier: 1,
batch_image_discount_multiplier: 0.5,
batch_image_hold_multiplier: 0.6,
image_price_1k: null,
image_price_2k: null,
image_price_4k: null,
video_rate_independent: false,
video_rate_multiplier: 1,
video_price_480p: null,
video_price_720p: null,
video_price_1080p: null,
web_search_price_per_call: null,
peak_rate_enabled: false,
peak_start: '',
peak_end: '',
peak_rate_multiplier: 1,
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-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
model_routing: null,
model_routing_enabled: false,
mcp_xml_inject: true,
supported_model_scopes: [],
account_count: 1,
active_account_count: 1,
rate_limited_account_count: 0,
models_list_config: undefined,
sort_order: 10
}
const AppLayoutStub = defineComponent({
template: '<main><slot /></main>'
})
const TablePageLayoutStub = defineComponent({
template: '<section><slot name="filters" /><slot name="table" /><slot name="pagination" /></section>'
})
const DataTableStub = defineComponent({
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
},
template: '<div><div v-for="row in data" :key="row.id"><slot name="cell-actions" :row="row" /></div></div>'
})
function mountView() {
return mount(GroupsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
Pagination: true,
BaseDialog: true,
ConfirmDialog: true,
EmptyState: true,
Select: true,
PlatformIcon: true,
Icon: true,
GroupCapacityBadge: true,
GroupRateMultipliersModal: true,
GroupRPMOverridesModal: true,
VueDraggable: true
}
}
})
}
describe('GroupsView duplicate action', () => {
beforeEach(() => {
localStorage.clear()
vi.spyOn(console, 'error').mockImplementation(() => {})
for (const fn of [
listGroups,
duplicateGroup,
getModelsListCandidates,
getUsageSummary,
getCapacitySummary,
showSuccess,
showError
]) {
fn.mockReset()
}
listGroups.mockResolvedValue({
items: [sourceGroup],
total: 1,
page: 1,
page_size: 20,
pages: 1
})
duplicateGroup.mockResolvedValue({
...sourceGroup,
id: 43,
name: 'Primary (Copy)',
status: 'inactive'
})
getModelsListCandidates.mockResolvedValue([])
getUsageSummary.mockResolvedValue([])
getCapacitySummary.mockResolvedValue([])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('duplicates the selected group, reports success, and refreshes the list', async () => {
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
await flushPromises()
expect(duplicateGroup).toHaveBeenCalledTimes(1)
expect(duplicateGroup).toHaveBeenCalledWith(42)
expect(showSuccess).toHaveBeenCalledWith('admin.groups.duplicateSuccess')
expect(listGroups).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('ignores repeated clicks while the duplicate request is in flight', async () => {
let resolveDuplicate!: (value: AdminGroup) => void
duplicateGroup.mockImplementationOnce(
() => new Promise<AdminGroup>((resolve) => { resolveDuplicate = resolve })
)
const wrapper = mountView()
await flushPromises()
const button = wrapper.get('[data-testid="group-duplicate"]')
void button.trigger('click')
void button.trigger('click')
await wrapper.vm.$nextTick()
expect(duplicateGroup).toHaveBeenCalledTimes(1)
expect(button.attributes('disabled')).toBeDefined()
expect(button.attributes('title')).toBe('admin.groups.duplicating')
resolveDuplicate({ ...sourceGroup, id: 43, name: 'Primary (Copy)', status: 'inactive' })
await flushPromises()
expect(wrapper.get('[data-testid="group-duplicate"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('shows the API error and restores the action when duplication fails', async () => {
duplicateGroup.mockRejectedValueOnce(new Error('duplicate failed'))
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
await flushPromises()
expect(showError).toHaveBeenCalledWith('duplicate failed')
expect(wrapper.get('[data-testid="group-duplicate"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('does not report a successful duplicate as failed when the refresh fails', async () => {
listGroups
.mockResolvedValueOnce({
items: [sourceGroup],
total: 1,
page: 1,
page_size: 20,
pages: 1
})
.mockRejectedValueOnce(new Error('refresh failed'))
const wrapper = mountView()
await flushPromises()
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
await flushPromises()
expect(showSuccess).toHaveBeenCalledWith('admin.groups.duplicateSuccess')
expect(showError).toHaveBeenCalledWith('admin.groups.failedToLoad')
expect(showError).not.toHaveBeenCalledWith('admin.groups.duplicateFailed')
wrapper.unmount()
})
})