fix logger and redeem admin review findings
This commit is contained in:
@@ -11,6 +11,16 @@ import type {
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
export interface RedeemBatchDeleteSkipped {
|
||||
id: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface RedeemBatchDeleteResult {
|
||||
deleted_ids: number[]
|
||||
skipped: RedeemBatchDeleteSkipped[]
|
||||
}
|
||||
|
||||
/**
|
||||
* List all redeem codes with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
@@ -102,14 +112,8 @@ export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
* @param ids - Array of redeem code IDs
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function batchDelete(ids: number[]): Promise<{
|
||||
deleted: number
|
||||
message: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
deleted: number
|
||||
message: string
|
||||
}>('/admin/redeem-codes/batch-delete', { ids })
|
||||
export async function batchDelete(ids: number[]): Promise<RedeemBatchDeleteResult> {
|
||||
const { data } = await apiClient.post<RedeemBatchDeleteResult>('/admin/redeem-codes/batch-delete', { ids })
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('UserEditModal', () => {
|
||||
// username 输入框是第二个 text input(第一个是密码)
|
||||
const textInputs = wrapper.findAll('input[type="text"]')
|
||||
// 找到 username 输入框(它有一个 label)
|
||||
const usernameInput = textInputs.find(input => {
|
||||
const _usernameInput = textInputs.find(input => {
|
||||
const parent = input.element.closest('div')
|
||||
return parent?.querySelector('label')?.textContent?.includes('admin.users.username')
|
||||
}) || textInputs[1]
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('AppSidebar custom SVG styles', () => {
|
||||
|
||||
describe('AppSidebar header styles', () => {
|
||||
it('does not clip the version badge dropdown', () => {
|
||||
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/)
|
||||
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n {2}\}/)
|
||||
|
||||
expect(sidebarHeaderBlockMatch).not.toBeNull()
|
||||
expect(sidebarHeaderBlockMatch?.[0]).not.toContain('@apply overflow-hidden;')
|
||||
|
||||
@@ -3195,6 +3195,8 @@ export default {
|
||||
codesExported: 'Codes exported successfully',
|
||||
codeDeleted: 'Redeem code deleted successfully',
|
||||
codesDeleted: 'Successfully deleted {count} unused code(s)',
|
||||
codesDeletedPartial: 'Deleted {deleted} unused code(s); {failed} could not be deleted',
|
||||
codesDeleteSkipped: 'No codes were deleted; {failed} code(s) could not be deleted',
|
||||
noUnusedCodes: 'No unused codes to delete',
|
||||
failedToLoad: 'Failed to load redeem codes',
|
||||
failedToGenerate: 'Failed to generate codes',
|
||||
|
||||
@@ -3326,6 +3326,8 @@ export default {
|
||||
codesExported: '兑换码导出成功',
|
||||
codeDeleted: '兑换码删除成功',
|
||||
codesDeleted: '成功删除 {count} 个未使用的兑换码',
|
||||
codesDeletedPartial: '已删除 {deleted} 个未使用兑换码,还有 {failed} 个删除失败',
|
||||
codesDeleteSkipped: '未能删除任何兑换码,共 {failed} 个删除失败',
|
||||
noUnusedCodes: '没有未使用的兑换码可删除',
|
||||
userPrefix: '用户 #{id}',
|
||||
failedToExport: '导出兑换码失败',
|
||||
|
||||
@@ -408,6 +408,7 @@ import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { deleteUnusedRedeemCodes } from './redeemDeleteUnused'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
@@ -736,18 +737,20 @@ const confirmDelete = async () => {
|
||||
|
||||
const confirmDeleteUnused = async () => {
|
||||
try {
|
||||
// Get all unused codes and delete them
|
||||
const unusedCodesResponse = await adminAPI.redeem.list(1, 1000, { status: 'unused' })
|
||||
const unusedCodeIds = unusedCodesResponse.items.map((code) => code.id)
|
||||
const result = await deleteUnusedRedeemCodes(adminAPI.redeem)
|
||||
const deleted = result.deletedIds.length
|
||||
const skipped = result.skipped.length
|
||||
|
||||
if (unusedCodeIds.length === 0) {
|
||||
if (deleted === 0 && skipped === 0) {
|
||||
appStore.showInfo(t('admin.redeem.noUnusedCodes'))
|
||||
showDeleteUnusedDialog.value = false
|
||||
return
|
||||
} else if (skipped === 0) {
|
||||
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: deleted }))
|
||||
} else if (deleted > 0) {
|
||||
appStore.showWarning(t('admin.redeem.codesDeletedPartial', { deleted, failed: skipped }))
|
||||
} else {
|
||||
appStore.showInfo(t('admin.redeem.codesDeleteSkipped', { failed: skipped }))
|
||||
}
|
||||
|
||||
const result = await adminAPI.redeem.batchDelete(unusedCodeIds)
|
||||
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: result.deleted }))
|
||||
showDeleteUnusedDialog.value = false
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -240,7 +240,7 @@ onMounted(loadAll)
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<Icon name="video" size="md" class="text-blue-600 dark:text-blue-400" />
|
||||
<Icon name="chartBar" size="md" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
@@ -256,7 +256,7 @@ onMounted(loadAll)
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||
<Icon name="activity" size="md" class="text-green-600 dark:text-green-400" />
|
||||
<Icon name="refresh" size="md" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -53,13 +53,6 @@ const IconStub = defineComponent({
|
||||
template: '<span class="icon" />'
|
||||
})
|
||||
|
||||
const BaseButtonStub = defineComponent({
|
||||
name: 'BaseButton',
|
||||
props: ['variant', 'size'],
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
})
|
||||
|
||||
function createMockSystemStats(overrides: Partial<SoraSystemStats> = {}): SoraSystemStats {
|
||||
return {
|
||||
total_users: 10,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PaginatedResponse, RedeemCode } from '@/types'
|
||||
import { deleteUnusedRedeemCodes } from '../redeemDeleteUnused'
|
||||
|
||||
function createCode(id: number): RedeemCode {
|
||||
return {
|
||||
id,
|
||||
code: `CODE-${id}`,
|
||||
type: 'balance',
|
||||
value: 10,
|
||||
status: 'unused',
|
||||
used_by: undefined,
|
||||
used_at: '',
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
} as RedeemCode
|
||||
}
|
||||
|
||||
function paginated(items: RedeemCode[]): PaginatedResponse<RedeemCode> {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('deleteUnusedRedeemCodes', () => {
|
||||
it('deletes all unused redeem codes across multiple pages', async () => {
|
||||
const client = {
|
||||
list: vi.fn()
|
||||
.mockResolvedValueOnce(paginated([createCode(1), createCode(2)]))
|
||||
.mockResolvedValueOnce(paginated([createCode(3)]))
|
||||
.mockResolvedValueOnce(paginated([])),
|
||||
batchDelete: vi.fn()
|
||||
.mockResolvedValueOnce({ deleted_ids: [1, 2], skipped: [] })
|
||||
.mockResolvedValueOnce({ deleted_ids: [3], skipped: [] })
|
||||
}
|
||||
|
||||
const result = await deleteUnusedRedeemCodes(client)
|
||||
|
||||
expect(client.list).toHaveBeenCalledTimes(3)
|
||||
expect(client.batchDelete).toHaveBeenCalledTimes(2)
|
||||
expect(client.batchDelete).toHaveBeenNthCalledWith(1, [1, 2])
|
||||
expect(client.batchDelete).toHaveBeenNthCalledWith(2, [3])
|
||||
expect(result).toEqual({ deletedIds: [1, 2, 3], skipped: [] })
|
||||
})
|
||||
|
||||
it('stops and returns partial results when a batch delete has skipped items', async () => {
|
||||
const client = {
|
||||
list: vi.fn().mockResolvedValueOnce(paginated([createCode(1), createCode(2)])),
|
||||
batchDelete: vi.fn().mockResolvedValueOnce({
|
||||
deleted_ids: [1],
|
||||
skipped: [{ id: 2, reason: 'db error' }]
|
||||
})
|
||||
}
|
||||
|
||||
const result = await deleteUnusedRedeemCodes(client)
|
||||
|
||||
expect(client.list).toHaveBeenCalledTimes(1)
|
||||
expect(client.batchDelete).toHaveBeenCalledWith([1, 2])
|
||||
expect(result).toEqual({
|
||||
deletedIds: [1],
|
||||
skipped: [{ id: 2, reason: 'db error' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns immediately when there are no unused redeem codes', async () => {
|
||||
const client = {
|
||||
list: vi.fn().mockResolvedValueOnce(paginated([])),
|
||||
batchDelete: vi.fn()
|
||||
}
|
||||
|
||||
const result = await deleteUnusedRedeemCodes(client)
|
||||
|
||||
expect(client.batchDelete).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ deletedIds: [], skipped: [] })
|
||||
})
|
||||
})
|
||||
46
frontend/src/views/admin/redeemDeleteUnused.ts
Normal file
46
frontend/src/views/admin/redeemDeleteUnused.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { PaginatedResponse, RedeemCode } from '@/types'
|
||||
import type { RedeemBatchDeleteResult } from '@/api/admin/redeem'
|
||||
|
||||
type RedeemListClient = (
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
filters?: {
|
||||
status?: 'active' | 'used' | 'expired' | 'unused'
|
||||
}
|
||||
) => Promise<PaginatedResponse<RedeemCode>>
|
||||
|
||||
type RedeemBatchDeleteClient = (ids: number[]) => Promise<RedeemBatchDeleteResult>
|
||||
|
||||
export interface DeleteUnusedRedeemCodesClient {
|
||||
list: RedeemListClient
|
||||
batchDelete: RedeemBatchDeleteClient
|
||||
}
|
||||
|
||||
export interface DeleteUnusedRedeemCodesResult {
|
||||
deletedIds: number[]
|
||||
skipped: RedeemBatchDeleteResult['skipped']
|
||||
}
|
||||
|
||||
export async function deleteUnusedRedeemCodes(
|
||||
client: DeleteUnusedRedeemCodesClient,
|
||||
pageSize: number = 1000
|
||||
): Promise<DeleteUnusedRedeemCodesResult> {
|
||||
const deletedIds: number[] = []
|
||||
const skipped: RedeemBatchDeleteResult['skipped'] = []
|
||||
|
||||
for (;;) {
|
||||
const response = await client.list(1, pageSize, { status: 'unused' })
|
||||
const ids = response.items.map((code) => code.id)
|
||||
if (ids.length === 0) {
|
||||
return { deletedIds, skipped }
|
||||
}
|
||||
|
||||
const result = await client.batchDelete(ids)
|
||||
deletedIds.push(...result.deleted_ids)
|
||||
skipped.push(...result.skipped)
|
||||
|
||||
if (result.skipped.length > 0) {
|
||||
return { deletedIds, skipped }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user