@@ -445,10 +612,18 @@ import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
+import { useTableSelection } from '@/composables/useTableSelection'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
-import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
+import type {
+ RedeemCode,
+ RedeemCodeType,
+ Group,
+ GroupPlatform,
+ SubscriptionType,
+ BatchUpdateRedeemCodeFields
+} from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -492,6 +667,11 @@ const subscriptionGroupOptions = computed(() => {
}))
})
+const batchGroupOptions = computed(() => [
+ { value: null, label: t('admin.redeem.clearGroup') },
+ ...subscriptionGroupOptions.value
+])
+
const generatedCodesText = computed(() => {
return generatedCodes.value.map((code) => code.code).join('\n')
})
@@ -540,6 +720,7 @@ const downloadGeneratedCodes = () => {
}
const columns = computed
(() => [
+ { key: 'select', label: '' },
{ key: 'code', label: t('admin.redeem.columns.code') },
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
@@ -569,12 +750,24 @@ const filterStatusOptions = computed(() => [
{ value: '', label: t('admin.redeem.allStatus') },
{ value: 'unused', label: t('admin.redeem.unused') },
{ value: 'used', label: t('admin.redeem.used') },
- { value: 'expired', label: t('admin.redeem.status.expired') }
+ { value: 'expired', label: t('admin.redeem.status.expired') },
+ { value: 'disabled', label: t('admin.redeem.status.disabled') }
+])
+
+const batchStatusOptions = computed(() => [
+ { value: 'unused', label: t('admin.redeem.status.unused') },
+ { value: 'disabled', label: t('admin.redeem.status.disabled') }
+])
+
+const batchExpiryModeOptions = computed(() => [
+ { value: 'clear', label: t('admin.redeem.neverExpires') },
+ { value: 'custom', label: t('admin.redeem.customExpiry') }
])
const codes = ref([])
const loading = ref(false)
const generating = ref(false)
+const batchUpdating = ref(false)
const searchQuery = ref('')
const filters = reactive({
type: '',
@@ -595,9 +788,35 @@ let abortController: AbortController | null = null
const showDeleteDialog = ref(false)
const showDeleteUnusedDialog = ref(false)
+const showBatchUpdateDialog = ref(false)
const deletingCode = ref(null)
const copiedCode = ref(null)
+const {
+ selectedSet: selectedCodeIds,
+ selectedCount,
+ allVisibleSelected,
+ select,
+ deselect,
+ clear: clearSelectedCodes,
+ toggleVisible
+} = useTableSelection({
+ rows: codes,
+ getId: (code) => code.id
+})
+
+const batchUpdateForm = reactive({
+ update_status: false,
+ status: 'disabled' as 'unused' | 'disabled',
+ update_expires_at: false,
+ expires_mode: 'clear' as 'clear' | 'custom',
+ expires_at_local: '',
+ update_notes: false,
+ notes: '',
+ update_group_id: false,
+ group_id: null as number | null
+})
+
type RedeemCodeExpiryOption = 'never' | '1' | '3' | '7' | 'custom'
const redeemCodeExpiryOptions = computed<{ value: RedeemCodeExpiryOption; label: string }[]>(() => [
@@ -632,7 +851,7 @@ watch(
const buildRedeemQueryFilters = () => ({
type: (filters.type || undefined) as RedeemCodeType | undefined,
- status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
+ status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | 'disabled' | undefined,
search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
@@ -705,6 +924,20 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
loadCodes()
}
+const toggleSelectRow = (id: number, event: Event) => {
+ const target = event.target as HTMLInputElement
+ if (target.checked) {
+ select(id)
+ return
+ }
+ deselect(id)
+}
+
+const toggleSelectAllVisible = (event: Event) => {
+ const target = event.target as HTMLInputElement
+ toggleVisible(target.checked)
+}
+
const getRedeemCodeExpiresInDays = () => {
if (generateForm.expiry_option === 'never') {
return undefined
@@ -721,6 +954,69 @@ const getRedeemCodeExpiresInDays = () => {
return Number(generateForm.expiry_option)
}
+const toDatetimeLocalInputValue = (date: Date) => {
+ const pad = (value: number) => String(value).padStart(2, '0')
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
+ date.getHours()
+ )}:${pad(date.getMinutes())}`
+}
+
+const resetBatchUpdateForm = () => {
+ batchUpdateForm.update_status = false
+ batchUpdateForm.status = 'disabled'
+ batchUpdateForm.update_expires_at = false
+ batchUpdateForm.expires_mode = 'clear'
+ batchUpdateForm.expires_at_local = toDatetimeLocalInputValue(
+ new Date(Date.now() + 24 * 60 * 60 * 1000)
+ )
+ batchUpdateForm.update_notes = false
+ batchUpdateForm.notes = ''
+ batchUpdateForm.update_group_id = false
+ batchUpdateForm.group_id = null
+}
+
+const openBatchUpdateDialog = () => {
+ if (selectedCount.value === 0) {
+ appStore.showInfo(t('admin.redeem.selectCodesFirst'))
+ return
+ }
+ resetBatchUpdateForm()
+ showBatchUpdateDialog.value = true
+}
+
+const closeBatchUpdateDialog = () => {
+ showBatchUpdateDialog.value = false
+}
+
+const buildBatchUpdateFields = (): BatchUpdateRedeemCodeFields | null => {
+ const fields: BatchUpdateRedeemCodeFields = {}
+
+ if (batchUpdateForm.update_status) {
+ fields.status = batchUpdateForm.status
+ }
+ if (batchUpdateForm.update_expires_at) {
+ if (batchUpdateForm.expires_mode === 'clear') {
+ fields.expires_at = null
+ } else {
+ const expiresAt = new Date(batchUpdateForm.expires_at_local)
+ if (!batchUpdateForm.expires_at_local || Number.isNaN(expiresAt.getTime())) {
+ appStore.showError(t('admin.redeem.expiryDaysRequired'))
+ return null
+ }
+ fields.expires_at = expiresAt.toISOString()
+ }
+ }
+ if (batchUpdateForm.update_notes) {
+ fields.notes = batchUpdateForm.notes
+ }
+ if (batchUpdateForm.update_group_id) {
+ fields.group_id =
+ batchUpdateForm.group_id == null ? null : Number(batchUpdateForm.group_id)
+ }
+
+ return Object.keys(fields).length > 0 ? fields : null
+}
+
const handleGenerateCodes = async () => {
// 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) {
@@ -834,6 +1130,43 @@ const confirmDeleteUnused = async () => {
}
}
+const handleBatchUpdate = async () => {
+ const ids = Array.from(selectedCodeIds.value)
+ if (ids.length === 0) {
+ appStore.showInfo(t('admin.redeem.selectCodesFirst'))
+ return
+ }
+
+ const hasSelectedFields =
+ batchUpdateForm.update_status ||
+ batchUpdateForm.update_expires_at ||
+ batchUpdateForm.update_notes ||
+ batchUpdateForm.update_group_id
+ if (!hasSelectedFields) {
+ appStore.showError(t('admin.redeem.noBatchFieldsSelected'))
+ return
+ }
+
+ const fields = buildBatchUpdateFields()
+ if (!fields) {
+ return
+ }
+
+ batchUpdating.value = true
+ try {
+ const result = await adminAPI.redeem.batchUpdate(ids, fields)
+ appStore.showSuccess(t('admin.redeem.batchUpdateSuccess', { count: result.updated }))
+ showBatchUpdateDialog.value = false
+ clearSelectedCodes()
+ loadCodes()
+ } catch (error: any) {
+ appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToBatchUpdate'))
+ console.error('Error batch updating codes:', error)
+ } finally {
+ batchUpdating.value = false
+ }
+}
+
// 加载订阅类型分组
const loadSubscriptionGroups = async () => {
try {
diff --git a/frontend/src/views/admin/__tests__/RedeemView.batchUpdate.spec.ts b/frontend/src/views/admin/__tests__/RedeemView.batchUpdate.spec.ts
new file mode 100644
index 00000000..083d374f
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/RedeemView.batchUpdate.spec.ts
@@ -0,0 +1,187 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+
+import RedeemView from '../RedeemView.vue'
+
+const { listRedeemCodes, batchUpdateRedeemCodes, getAllGroups, showSuccess, showError, showInfo } =
+ vi.hoisted(() => ({
+ listRedeemCodes: vi.fn(),
+ batchUpdateRedeemCodes: vi.fn(),
+ getAllGroups: vi.fn(),
+ showSuccess: vi.fn(),
+ showError: vi.fn(),
+ showInfo: vi.fn()
+ }))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ redeem: {
+ list: listRedeemCodes,
+ generate: vi.fn(),
+ delete: vi.fn(),
+ batchDelete: vi.fn(),
+ batchUpdate: batchUpdateRedeemCodes,
+ exportCodes: vi.fn()
+ },
+ groups: {
+ getAll: getAllGroups
+ }
+ }
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showSuccess,
+ showError,
+ showInfo
+ })
+}))
+
+vi.mock('@/composables/useClipboard', () => ({
+ useClipboard: () => ({
+ copyToClipboard: vi.fn()
+ })
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+const DataTableStub = {
+ props: ['columns', 'data'],
+ template: `
+
+
+
+ |
+ {{ column.label }}
+ |
+
+
+
+
+ |
+
+ {{ row[column.key] }}
+
+ |
+
+
+
+ `
+}
+
+const SelectStub = {
+ props: ['modelValue', 'options'],
+ emits: ['update:modelValue', 'change'],
+ setup(props: { options: Array<{ value: unknown; label: string }> }, { emit }: { emit: (event: string, ...args: unknown[]) => void }) {
+ const onChange = (event: Event) => {
+ const raw = (event.target as HTMLSelectElement).value
+ const option = props.options.find((item) => String(item.value ?? '') === raw)
+ const value = option ? option.value : raw
+ emit('update:modelValue', value)
+ emit('change', value, option ?? null)
+ }
+ return { onChange }
+ },
+ template: `
+
+ `
+}
+
+describe('admin RedeemView batch update', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ document.body.innerHTML = ''
+
+ listRedeemCodes.mockReset()
+ batchUpdateRedeemCodes.mockReset()
+ getAllGroups.mockReset()
+ showSuccess.mockReset()
+ showError.mockReset()
+ showInfo.mockReset()
+
+ listRedeemCodes.mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ code: 'CODE-1',
+ type: 'balance',
+ value: 10,
+ status: 'unused',
+ used_by: null,
+ used_at: null,
+ created_at: '2026-01-01T00:00:00Z',
+ expires_at: null
+ },
+ {
+ id: 2,
+ code: 'CODE-2',
+ type: 'balance',
+ value: 20,
+ status: 'unused',
+ used_by: null,
+ used_at: null,
+ created_at: '2026-01-01T00:00:00Z',
+ expires_at: null
+ }
+ ],
+ total: 2,
+ page: 1,
+ page_size: 20,
+ pages: 1
+ })
+ batchUpdateRedeemCodes.mockResolvedValue({ updated: 1, message: 'ok' })
+ getAllGroups.mockResolvedValue([])
+ })
+
+ it('submits only checked fields for selected redeem codes', async () => {
+ const wrapper = mount(RedeemView, {
+ attachTo: document.body,
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: {
+ template: '
'
+ },
+ DataTable: DataTableStub,
+ Pagination: true,
+ ConfirmDialog: true,
+ Select: SelectStub,
+ GroupBadge: true,
+ GroupOptionItem: true,
+ Icon: true,
+ Teleport: true
+ }
+ }
+ })
+
+ await flushPromises()
+ await wrapper.findAll('[data-test="select-code"]')[0].setValue(true)
+ await wrapper.get('[data-test="batch-update-open"]').trigger('click')
+ await flushPromises()
+
+ await wrapper.get('[data-test="batch-field-status"]').setValue(true)
+ await wrapper.get('[data-test="batch-status-select"]').setValue('disabled')
+ await wrapper.get('[data-test="batch-field-notes"]').setValue(true)
+ await wrapper.get('[data-test="batch-notes-input"]').setValue('maintenance')
+ await wrapper.get('[data-test="batch-update-form"]').trigger('submit')
+ await flushPromises()
+
+ expect(batchUpdateRedeemCodes).toHaveBeenCalledWith([1], {
+ status: 'disabled',
+ notes: 'maintenance'
+ })
+ expect(showSuccess).toHaveBeenCalledWith('admin.redeem.batchUpdateSuccess')
+ })
+})