Commit 524455fa by CaIon

feat(redemptions): add batch deletion and optional file exports

Add confirmed multi-select deletion through one batch API request and one
soft-delete statement. Record the affected count and requested IDs in a
separate batch audit event, and identify legacy events with missing counts.

After creation, offer an unchecked Save as a file option with TXT/Markdown
formats and optional name/quota fields. Keep Done as the default completion
action. Include translations for all seven frontend locales.

Validation:
- Redemption and audit frontend regression tests, typecheck, and scoped lint.
- go build ./...
- go test ./controller -run '^TestDeleteRedemptionBatch$' -count=1 -v
  with TEST_MYSQL_DSN, TEST_MYSQL_LOG_DSN, TEST_POSTGRES_DSN,
  and TEST_POSTGRES_LOG_DSN set to isolated primary and log databases.
- Real SQLite 3.50.4, MySQL 8.4.11, and PostgreSQL 16.15 passed, including
  deletion of 15 records, duplicate/missing IDs, zero-row retries, audit
  deduplication, invalid input, and exclusion of credentials from logs.
parent 950644c9
......@@ -58,7 +58,8 @@ var auditContentTemplates = map[string]string{
"channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})",
"channel.upstream_apply_all": "Applied upstream model changes to ${count} channels",
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
"redemption.delete_batch": "Batch deleted ${count} redemption codes",
"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
......
......@@ -215,3 +215,24 @@ func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
}
return true, ""
}
func DeleteRedemptionBatch(c *gin.Context) {
var request struct {
Ids []int `json:"ids" binding:"required,min=1,max=1000,dive,gt=0"`
}
if err := c.ShouldBindJSON(&request); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
count, err := model.BatchDeleteRedemptions(request.Ids)
if err != nil {
common.ApiError(c, err)
return
}
recordManageAudit(c, "redemption.delete_batch", map[string]any{
"count": count,
"total": len(request.Ids),
"requested_redemption_ids": request.Ids,
})
common.ApiSuccess(c, count)
}
package controller
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestDeleteRedemptionBatch(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver, logDriver gorm.Dialector
dbType := common.DatabaseTypeSQLite
switch dialect {
case "sqlite":
driver = sqlite.Open(":memory:")
logDriver = sqlite.Open(":memory:")
case "mysql":
dsn := os.Getenv("TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("TEST_MYSQL_DSN is not configured")
}
driver = mysql.Open(dsn)
logDSN := os.Getenv("TEST_MYSQL_LOG_DSN")
if logDSN == "" {
logDSN = dsn
}
logDriver = mysql.Open(logDSN)
dbType = common.DatabaseTypeMySQL
case "postgres":
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN is not configured")
}
driver = postgres.Open(dsn)
logDSN := os.Getenv("TEST_POSTGRES_LOG_DSN")
if logDSN == "" {
logDSN = dsn
}
logDriver = postgres.Open(logDSN)
dbType = common.DatabaseTypePostgreSQL
}
db, err := gorm.Open(driver, &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
var version string
query := "SELECT version()"
if dialect == "sqlite" {
query = "SELECT sqlite_version()"
}
require.NoError(t, db.Raw(query).Scan(&version).Error)
t.Logf("database version: %s", version)
logDB, err := gorm.Open(logDriver, &gorm.Config{})
require.NoError(t, err)
logSQL, err := logDB.DB()
require.NoError(t, err)
logSQL.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, logSQL.Close()) })
previousDB, previousLogDB := model.DB, model.LOG_DB
previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
previousRedis := common.RedisEnabled
model.DB, model.LOG_DB = db, logDB
common.SetDatabaseTypes(dbType, dbType)
common.RedisEnabled = false
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMain, previousLog)
common.RedisEnabled = previousRedis
})
for _, table := range []any{&model.User{}, &model.Redemption{}} {
require.False(t, db.Migrator().HasTable(table), "use an empty test database")
require.NoError(t, db.AutoMigrate(table))
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(table)) })
}
require.False(t, logDB.Migrator().HasTable(&model.AuditLog{}), "use an empty test log database")
require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
t.Cleanup(func() { require.NoError(t, logDB.Migrator().DropTable(&model.AuditLog{})) })
token := "redemption-audit-test-token"
admin := model.User{Username: "redemption-audit-admin", Password: "unused", Role: common.RoleAdminUser, Status: common.UserStatusEnabled, Group: "default", AccessToken: &token}
require.NoError(t, db.Create(&admin).Error)
codes := make([]model.Redemption, 16)
for index := range codes {
codes[index] = model.Redemption{Name: "selected", Key: fmt.Sprintf("%032d", index+1), Quota: 100, Status: common.RedemptionCodeStatusEnabled}
}
codes[1].Status = common.RedemptionCodeStatusUsed
codes[15].Name = "unselected"
codes[15].Status = common.RedemptionCodeStatusDisabled
require.NoError(t, model.DB.Create(&codes).Error)
router := gin.New()
router.Use(middleware.RequestId())
router.POST("/api/redemption/batch", middleware.AdminAuth(), DeleteRedemptionBatch)
overLimit := make([]int, 1001)
for index := range overLimit {
overLimit[index] = codes[0].Id
}
oversized, err := common.Marshal(map[string]any{"ids": overLimit})
require.NoError(t, err)
for _, body := range []string{"{}", `{"ids":[]}`, `{"ids":null}`, `{"ids":[0]}`, `{"ids":[1,-1]}`, `{"ids":["1"]}`, "{", string(oversized)} {
t.Run("invalid_"+body[:min(len(body), 30)], func(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/redemption/batch", bytes.NewBufferString(body))
request.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(response, request)
var result struct {
Success bool `json:"success"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success)
var count int64
require.NoError(t, model.DB.Model(&model.Redemption{}).Count(&count).Error)
assert.EqualValues(t, 16, count)
var events []model.AuditLog
require.NoError(t, logDB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryOperation).Find(&events).Error)
require.Len(t, events, 1)
assert.False(t, events[0].Success)
assert.Equal(t, "redemption.delete_batch", events[0].Action)
})
}
_, err = model.BatchDeleteRedemptions(nil)
require.Error(t, err)
requestedIDs := make([]int, 0, 17)
for _, code := range codes[:15] {
requestedIDs = append(requestedIDs, code.Id)
}
requestedIDs = append(requestedIDs, codes[0].Id, 999999)
payload, err := common.Marshal(map[string]any{"ids": requestedIDs})
require.NoError(t, err)
for _, expectedCount := range []int64{15, 0} {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/redemption/batch", bytes.NewReader(payload))
request.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var result struct {
Success bool `json:"success"`
Data int64 `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.True(t, result.Success)
assert.Equal(t, expectedCount, result.Data)
var events []model.AuditLog
require.NoError(t, logDB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryOperation).Find(&events).Error)
require.Len(t, events, 1, "one operation event, without a duplicate single-delete fallback")
event := events[0]
assert.Equal(t, "redemption.delete_batch", event.Action)
assert.Equal(t, fmt.Sprintf("Batch deleted %d redemption codes", expectedCount), event.Content)
assert.True(t, event.Success)
assert.Equal(t, admin.Id, event.UserId)
assert.Equal(t, "/api/redemption/batch", event.Route)
require.NotNil(t, event.Other.Op)
encoded, err := common.Marshal(event.Other.Op.Params)
require.NoError(t, err)
var params struct {
Count int64 `json:"count"`
Total int `json:"total"`
IDs []int `json:"requested_redemption_ids"`
}
require.NoError(t, common.Unmarshal(encoded, &params))
assert.Equal(t, expectedCount, params.Count)
assert.Equal(t, len(requestedIDs), params.Total)
assert.Equal(t, requestedIDs, params.IDs)
encoded, err = common.Marshal(event)
require.NoError(t, err)
assert.NotContains(t, string(encoded), token)
for _, code := range codes {
assert.NotContains(t, string(encoded), code.Key)
}
}
var active []model.Redemption
require.NoError(t, model.DB.Find(&active).Error)
require.Len(t, active, 1)
assert.Equal(t, codes[15], active[0])
var all []model.Redemption
require.NoError(t, model.DB.Unscoped().Order("id").Find(&all).Error)
require.Len(t, all, 16)
for _, code := range all[:15] {
assert.True(t, code.DeletedAt.Valid)
}
assert.False(t, all[15].DeletedAt.Valid)
})
}
}
......@@ -64,6 +64,7 @@ var auditRouteActions = map[string]string{
// 兑换码
"PUT /api/redemption/": "redemption.update",
"POST /api/redemption/batch": "redemption.delete_batch",
"DELETE /api/redemption/:id": "redemption.delete",
"DELETE /api/redemption/invalid": "redemption.delete_invalid",
......
......@@ -239,3 +239,17 @@ func DeleteInvalidRedemptions() (int64, error) {
result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{})
return result.RowsAffected, result.Error
}
// BatchDeleteRedemptions soft-deletes the selected codes in one statement.
func BatchDeleteRedemptions(ids []int) (int64, error) {
if len(ids) == 0 || len(ids) > 1000 {
return 0, errors.New("select between 1 and 1000 redemption codes")
}
for _, id := range ids {
if id <= 0 {
return 0, errors.New("redemption IDs must be positive")
}
}
result := DB.Where("id IN ?", ids).Delete(&Redemption{})
return result.RowsAffected, result.Error
}
......@@ -298,6 +298,7 @@ func SetApiRouter(router *gin.Engine) {
redemptionRoute.GET("/search", controller.SearchRedemptions)
redemptionRoute.GET("/:id", controller.GetRedemption)
redemptionRoute.POST("/", controller.AddRedemption)
redemptionRoute.POST("/batch", controller.DeleteRedemptionBatch)
redemptionRoute.PUT("/", controller.UpdateRedemption)
redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption)
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
......
......@@ -98,3 +98,10 @@ export async function deleteInvalidRedemptions(): Promise<ApiResponse<number>> {
const res = await api.delete('/api/redemption/invalid')
return res.data
}
export async function batchDeleteRedemptions(
ids: number[]
): Promise<ApiResponse<number>> {
const res = await api.post('/api/redemption/batch', { ids })
return res.data
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Toaster, toast } from 'sonner'
import { afterEach, expect, test, vi } from 'vitest'
import { useDataTable } from '@/components/data-table'
import { api } from '@/lib/api'
import { getRedemptions } from '../../api'
import type { Redemption } from '../../types'
import { DataTableBulkActions } from '../data-table-bulk-actions'
import { RedemptionsProvider, useRedemptions } from '../redemptions-provider'
const codes: Redemption[] = [11, 22, 33].map((id) => ({
id,
user_id: 1,
name: `code-${id}`,
key: `key-${id}`,
status: 1,
quota: 100,
created_time: 1,
redeemed_time: 0,
expired_time: 0,
used_user_id: 0,
}))
const columns = [{ accessorKey: 'name' }]
const clients: QueryClient[] = []
function RedemptionList() {
const { refreshTrigger } = useRedemptions()
const { data } = useQuery({
queryKey: ['redemptions', refreshTrigger],
queryFn: () => getRedemptions(),
})
const { table } = useDataTable({
data: data?.data?.items ?? [],
columns,
enableRowSelection: true,
getRowId: (row) => String(row.id),
})
return (
<>
{table.getRowModel().rows.map((row) => (
<label key={row.id}>
<input
type='checkbox'
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
{row.original.name}
</label>
))}
<DataTableBulkActions table={table} />
</>
)
}
async function setup() {
let remaining = [...codes]
vi.spyOn(api, 'get').mockImplementation(async () => ({
data: {
success: true,
data: { items: remaining, total: remaining.length },
},
}))
const remove = vi
.spyOn(api, 'post')
.mockImplementation(async (_url, body) => {
const { ids } = body as { ids: number[] }
const count = remaining.filter((code) => ids.includes(code.id)).length
remaining = remaining.filter((code) => !ids.includes(code.id))
return { data: { success: true, data: count } }
})
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
clients.push(client)
render(
<QueryClientProvider client={client}>
<RedemptionsProvider>
<RedemptionList />
</RedemptionsProvider>
<Toaster />
</QueryClientProvider>
)
await screen.findByRole('checkbox', { name: 'code-11' })
return { user: userEvent.setup(), remove }
}
afterEach(() => {
toast.dismiss()
for (const client of clients) client.clear()
clients.length = 0
})
test('shows deletion only for selected codes and cancellation sends no requests', async () => {
const { user, remove } = await setup()
expect(
screen.queryByRole('button', { name: 'Delete selected redemption codes' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('checkbox', { name: 'code-11' }))
const button = screen.getByRole('button', {
name: 'Delete selected redemption codes',
})
button.focus()
await user.keyboard('{Enter}')
const dialog = await screen.findByRole('alertdialog')
expect(dialog).toHaveAccessibleName('Delete 1 redemption codes?')
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(remove).not.toHaveBeenCalled()
expect(screen.getByRole('checkbox', { name: 'code-11' })).toBeChecked()
})
test('confirmation deletes only selected codes, disables repeat submissions and refreshes the list', async () => {
const { user, remove } = await setup()
let release!: () => void
const pending = new Promise<void>((resolve) => {
release = resolve
})
const originalDelete = remove.getMockImplementation()
if (!originalDelete) throw new Error('Missing batch delete mock')
remove.mockImplementationOnce(async (url, body) => {
await pending
return originalDelete(url, body)
})
await user.click(screen.getByRole('checkbox', { name: 'code-11' }))
await user.click(screen.getByRole('checkbox', { name: 'code-22' }))
await user.click(
screen.getByRole('button', { name: 'Delete selected redemption codes' })
)
const dialog = await screen.findByRole('alertdialog')
expect(dialog).toHaveAccessibleName('Delete 2 redemption codes?')
expect(remove).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
expect(
within(dialog).getByRole('button', { name: 'Deleting...' })
).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled()
release()
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
await waitFor(() =>
expect(
screen.queryByRole('checkbox', { name: 'code-11' })
).not.toBeInTheDocument()
)
expect(remove.mock.calls).toEqual([
['/api/redemption/batch', { ids: [11, 22] }],
])
expect(screen.getByRole('checkbox', { name: 'code-33' })).not.toBeChecked()
expect(screen.queryByRole('toolbar')).not.toBeInTheDocument()
expect(document.body).toHaveTextContent(
'Successfully deleted 2 redemption codes'
)
})
test.each(['server', 'network'])(
'%s failure keeps selected codes and retries with one batch request',
async (failure) => {
const { user, remove } = await setup()
if (failure === 'server') {
remove.mockResolvedValueOnce({ data: { success: false } })
} else {
remove.mockRejectedValueOnce(new Error('Network unavailable'))
}
await user.click(screen.getByRole('checkbox', { name: 'code-11' }))
await user.click(screen.getByRole('checkbox', { name: 'code-22' }))
await user.click(
screen.getByRole('button', { name: 'Delete selected redemption codes' })
)
const dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
await waitFor(() =>
expect(document.body).toHaveTextContent(
'Failed to delete 2 redemption codes'
)
)
expect(dialog).toHaveAccessibleName('Delete 2 redemption codes?')
expect(screen.getByLabelText('code-11')).toBeChecked()
expect(screen.getByLabelText('code-22')).toBeChecked()
await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(remove.mock.calls).toEqual([
['/api/redemption/batch', { ids: [11, 22] }],
['/api/redemption/batch', { ids: [11, 22] }],
])
expect(screen.queryByRole('toolbar')).not.toBeInTheDocument()
}
)
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, expect, test, vi } from 'vitest'
import { api } from '@/lib/api'
import {
DEFAULT_CURRENCY_CONFIG,
useSystemConfigStore,
} from '@/stores/system-config-store'
import { RedemptionsExportDialog } from '../redemptions-export-dialog'
import { RedemptionsMutateDrawer } from '../redemptions-mutate-drawer'
import { RedemptionsProvider } from '../redemptions-provider'
type Download = { filename: string; blob: Blob }
function captureDownloads() {
const downloads: Download[] = []
let currentBlob: Blob
vi.stubGlobal(
'URL',
Object.assign(class extends URL {}, {
createObjectURL: vi.fn((blob: Blob) => {
currentBlob = blob
return 'blob:redemption-export'
}),
revokeObjectURL: vi.fn(),
})
)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function (this: HTMLAnchorElement) {
downloads.push({ filename: this.download, blob: currentBlob })
}
)
return downloads
}
function readDownload(download: Download): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => resolve(String(reader.result)), {
once: true,
})
reader.addEventListener('error', () => reject(reader.error), { once: true })
reader.readAsText(download.blob)
})
}
function CreateDrawer() {
const [open, setOpen] = useState(true)
return (
<RedemptionsProvider>
<RedemptionsMutateDrawer open={open} onOpenChange={setOpen} />
</RedemptionsProvider>
)
}
afterEach(() => {
vi.unstubAllGlobals()
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
localStorage.clear()
})
test.each([
{
name: true,
quota: true,
txt: 'launch\tcodeA\t$10.00\nlaunch\tcodeB\t$10.00\n',
md: '| Name | Code | Quota |\n| --- | --- | --- |\n| launch | codeA | $10.00 |\n| launch | codeB | $10.00 |\n',
},
{
name: true,
quota: false,
txt: 'launch\tcodeA\nlaunch\tcodeB\n',
md: '| Name | Code |\n| --- | --- |\n| launch | codeA |\n| launch | codeB |\n',
},
{
name: false,
quota: true,
txt: 'codeA\t$10.00\ncodeB\t$10.00\n',
md: '| Code | Quota |\n| --- | --- |\n| codeA | $10.00 |\n| codeB | $10.00 |\n',
},
{
name: false,
quota: false,
txt: 'codeA\ncodeB\n',
md: '| Code |\n| --- |\n| codeA |\n| codeB |\n',
},
])(
'exports TXT and Markdown with name=$name and quota=$quota',
async (options) => {
const downloads = captureDownloads()
const user = userEvent.setup()
for (const format of ['txt', 'md'] as const) {
const onClose = vi.fn()
const view = render(
<RedemptionsExportDialog
data={{ keys: ['codeA', 'codeB'], name: 'launch', quota: '$10.00' }}
onClose={onClose}
/>
)
await user.click(screen.getByRole('checkbox', { name: 'Save as a file' }))
await user.click(
screen.getByRole('radio', {
name: format === 'txt' ? 'Save as TXT' : 'Save as Markdown',
})
)
const name = screen.getByRole('checkbox', { name: 'Include name' })
const quota = screen.getByRole('checkbox', { name: 'Include quota' })
expect(name).not.toHaveAttribute('aria-disabled', 'true')
expect(quota).not.toHaveAttribute('aria-disabled', 'true')
expect(name).toBeChecked()
expect(quota).toBeChecked()
if (!options.name) await user.click(name)
if (!options.quota) {
quota.focus()
await user.keyboard(' ')
}
await user.click(screen.getByRole('button', { name: 'Done' }))
const download = downloads[format === 'txt' ? 0 : 1]
expect(download.filename).toMatch(
new RegExp(`^redemption-codes-\\d+\\.${format}$`)
)
expect(download.blob.type).toBe(
format === 'txt'
? 'text/plain;charset=utf-8'
: 'text/markdown;charset=utf-8'
)
expect(await readDownload(download)).toBe(options[format])
expect(onClose).toHaveBeenCalledOnce()
view.unmount()
}
expect(downloads).toHaveLength(2)
}
)
test('keeps names containing table delimiters and markup inside one Markdown cell', async () => {
const downloads = captureDownloads()
const user = userEvent.setup()
render(
<RedemptionsExportDialog
data={{ keys: ['codeA'], name: 'A | [B]\n<x>', quota: '$10' }}
onClose={() => undefined}
/>
)
await user.click(screen.getByRole('checkbox', { name: 'Save as a file' }))
await user.click(screen.getByRole('radio', { name: 'Save as Markdown' }))
await user.click(screen.getByRole('button', { name: 'Done' }))
expect(await readDownload(downloads[0])).toBe(
'| Name | Code | Quota |\n| --- | --- | --- |\n| A \\| \\[B\\] \\<x\\> | codeA | $10 |\n'
)
})
test('successful batch creation opens export with returned codes and the configured currency', async () => {
const downloads = captureDownloads()
const user = userEvent.setup()
useSystemConfigStore.getState().setConfig({
currency: {
...DEFAULT_CURRENCY_CONFIG,
quotaDisplayType: 'CNY',
usdExchangeRate: 7.2,
},
})
vi.spyOn(api, 'post').mockResolvedValue({
data: { success: true, data: ['createdA', 'createdB'] },
})
render(<CreateDrawer />)
const createDialog = screen.getByRole('dialog', {
name: 'Create Redemption Code',
})
fireEvent.change(within(createDialog).getByLabelText('Name'), {
target: { value: 'batch' },
})
fireEvent.change(within(createDialog).getByLabelText('Quantity'), {
target: { value: '2' },
})
fireEvent.change(within(createDialog).getByLabelText('Quota (CNY)'), {
target: { value: '2000' },
})
await user.click(
within(createDialog).getByRole('button', { name: 'Save changes' })
)
const exportDialog = await screen.findByRole('dialog', {
name: 'Redemption codes created',
})
expect(exportDialog).toHaveTextContent(
'Successfully created 2 redemption codes'
)
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'Create Redemption Code' })
).not.toBeInTheDocument()
)
await user.click(
within(exportDialog).getByRole('checkbox', { name: 'Save as a file' })
)
await user.click(
within(exportDialog).getByRole('radio', { name: 'Save as TXT' })
)
expect(downloads).toHaveLength(0)
await user.click(within(exportDialog).getByRole('button', { name: 'Done' }))
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'Redemption codes created' })
).not.toBeInTheDocument()
)
expect(await readDownload(downloads[0])).toBe(
'batch\tcreatedA\t¥2,000\nbatch\tcreatedB\t¥2,000\n'
)
})
test('failed creation does not open a success export dialog', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'post').mockResolvedValue({
data: { success: false, message: 'Creation failed' },
})
render(<CreateDrawer />)
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'batch' },
})
await user.click(screen.getByRole('button', { name: 'Save changes' }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled()
)
expect(
screen.queryByRole('dialog', { name: 'Redemption codes created' })
).not.toBeInTheDocument()
expect(
screen.getByRole('dialog', { name: 'Create Redemption Code' })
).toBeVisible()
})
test('completion defaults to no file and closes without downloading', async () => {
const downloads = captureDownloads()
const user = userEvent.setup()
const onClose = vi.fn()
render(
<RedemptionsExportDialog
data={{ keys: ['codeA'], name: 'launch', quota: '$10' }}
onClose={onClose}
/>
)
expect(
screen.getByRole('checkbox', { name: 'Save as a file' })
).not.toBeChecked()
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
expect(
screen.queryByRole('checkbox', { name: 'Include name' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('checkbox', { name: 'Include quota' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Done' }))
expect(downloads).toEqual([])
expect(onClose).toHaveBeenCalledOnce()
})
test('unchecking save as a file hides export settings and completes without downloading', async () => {
const downloads = captureDownloads()
const user = userEvent.setup()
const onClose = vi.fn()
render(
<RedemptionsExportDialog
data={{ keys: ['codeA'], name: 'launch', quota: '$10' }}
onClose={onClose}
/>
)
const saveFile = screen.getByRole('checkbox', { name: 'Save as a file' })
saveFile.focus()
await user.keyboard(' ')
expect(screen.getByRole('radio', { name: 'Save as TXT' })).toBeChecked()
await user.click(screen.getByRole('radio', { name: 'Save as Markdown' }))
await user.click(saveFile)
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
expect(
screen.queryByRole('checkbox', { name: 'Include name' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('checkbox', { name: 'Include quota' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Done' }))
expect(downloads).toEqual([])
expect(onClose).toHaveBeenCalledOnce()
})
......@@ -16,44 +16,127 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMutation } from '@tanstack/react-query'
import type { Table } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { CopyButton } from '@/components/copy-button'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { batchDeleteRedemptions } from '../api'
import type { Redemption } from '../types'
import { useRedemptions } from './redemptions-provider'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
type DataTableBulkActionsProps = {
table: Table<Redemption>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
export function DataTableBulkActions(props: DataTableBulkActionsProps) {
const { t } = useTranslation()
const selectedRows = table.getSelectedRowModel().rows
const { triggerRefresh } = useRedemptions()
const [deleteTargets, setDeleteTargets] = useState<Redemption[] | null>(null)
const selectedRows = props.table.getFilteredSelectedRowModel().rows
const contentToCopy = useMemo(() => {
const selectedCodes = selectedRows.map((row) => {
const redemption = row.original as Redemption
const redemption = row.original
return `${redemption.name}\t${redemption.key}`
})
return selectedCodes.join('\n')
}, [selectedRows])
const deletion = useMutation({
mutationFn: async (targets: Redemption[]) => {
const result = await batchDeleteRedemptions(
targets.map((code) => code.id)
)
if (!result.success) throw new Error(result.message)
return result.data ?? 0
},
onSuccess: (count, targets) => {
toast.success(
t('Successfully deleted {{count}} redemption codes', { count })
)
props.table.setRowSelection((previous) => {
const next = { ...previous }
for (const code of targets) delete next[String(code.id)]
return next
})
setDeleteTargets(null)
triggerRefresh()
},
onError: (_error, targets) => {
toast.error(
t('Failed to delete {{count}} redemption codes', {
count: targets.length,
})
)
},
})
return (
<BulkActionsToolbar table={table} entityName={t('redemption code')}>
<CopyButton
value={contentToCopy}
variant='outline'
size='icon'
className='size-8'
tooltip={t('Copy selected codes')}
successTooltip={t('Codes copied!')}
aria-label={t('Copy selected codes')}
<>
<BulkActionsToolbar table={props.table} entityName={t('redemption code')}>
<CopyButton
value={contentToCopy}
variant='outline'
size='icon'
className='size-8'
tooltip={t('Copy selected codes')}
successTooltip={t('Codes copied!')}
aria-label={t('Copy selected codes')}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='destructive'
size='icon'
className='size-8'
aria-label={t('Delete selected redemption codes')}
disabled={deletion.isPending}
onClick={() =>
setDeleteTargets(selectedRows.map((row) => row.original))
}
/>
}
>
<Trash2 aria-hidden='true' />
</TooltipTrigger>
<TooltipContent>
{t('Delete selected redemption codes')}
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
<ConfirmDialog
destructive
open={deleteTargets !== null}
onOpenChange={(open) => {
if (!open && !deletion.isPending) setDeleteTargets(null)
}}
title={t('Delete {{count}} redemption codes?', {
count: deleteTargets?.length ?? 0,
})}
desc={t('This action cannot be undone.')}
confirmText={deletion.isPending ? t('Deleting...') : t('Delete')}
isLoading={deletion.isPending}
disabled={!deleteTargets?.length}
handleConfirm={() => {
if (deleteTargets?.length && !deletion.isPending) {
deletion.mutate(deleteTargets)
}
}}
/>
</BulkActionsToolbar>
</>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
export type RedemptionExportData = {
keys: string[]
name: string
quota: string
}
type RedemptionsExportDialogProps = {
data: RedemptionExportData
onClose: () => void
}
export function RedemptionsExportDialog(props: RedemptionsExportDialogProps) {
const { t } = useTranslation()
const id = useId()
const [saveToFile, setSaveToFile] = useState(false)
const [format, setFormat] = useState<'txt' | 'md'>('txt')
const [includeName, setIncludeName] = useState(true)
const [includeQuota, setIncludeQuota] = useState(true)
const handleComplete = () => {
if (!saveToFile) {
props.onClose()
return
}
const headers: string[] = []
if (includeName) headers.push(t('Name'))
headers.push(t('Code'))
if (includeQuota) headers.push(t('Quota'))
const rows = props.data.keys.map((key) => {
const row: string[] = []
if (includeName) row.push(props.data.name)
row.push(key)
if (includeQuota) row.push(props.data.quota)
return row.map((value) => value.replaceAll(/[\t\r\n]+/g, ' '))
})
let content = `${rows.map((row) => row.join('\t')).join('\n')}\n`
if (format === 'md') {
const markdownRows = [headers, headers.map(() => '---'), ...rows]
const markdownLines = markdownRows.map((row, index) => {
const cells =
index === 1
? row
: row.map((value) =>
value
.replaceAll('&', '&amp;')
.replaceAll(/[\\`*_[\]|<>]/g, '\\$&')
)
return `| ${cells.join(' | ')} |`
})
content = `${markdownLines.join('\n')}\n`
}
const blob = new Blob([content], {
type:
format === 'md'
? 'text/markdown;charset=utf-8'
: 'text/plain;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `redemption-codes-${Date.now()}.${format}`
document.body.append(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
props.onClose()
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) props.onClose()
}}
title={t('Redemption codes created')}
description={t('Successfully created {{count}} redemption codes', {
count: props.data.keys.length,
})}
contentClassName='sm:max-w-md'
bodyClassName='space-y-5'
footer={<Button onClick={handleComplete}>{t('Done')}</Button>}
>
<div className='flex items-center gap-2'>
<Checkbox
id={`${id}-save-file`}
checked={saveToFile}
onCheckedChange={setSaveToFile}
/>
<Label htmlFor={`${id}-save-file`}>{t('Save as a file')}</Label>
</div>
{saveToFile && (
<div className='space-y-4 pl-6'>
<RadioGroup
value={format}
onValueChange={(value) => {
if (value === 'txt' || value === 'md') {
setFormat(value)
}
}}
aria-label={t('Save redemption codes')}
className='flex flex-wrap gap-x-5 gap-y-3'
>
{[
{ value: 'txt', label: t('Save as TXT') },
{ value: 'md', label: t('Save as Markdown') },
].map((option) => (
<div key={option.value} className='flex items-center gap-2'>
<RadioGroupItem
value={option.value}
id={`${id}-${option.value}`}
/>
<Label htmlFor={`${id}-${option.value}`}>{option.label}</Label>
</div>
))}
</RadioGroup>
<div className='flex flex-wrap gap-6'>
<div className='flex items-center gap-2'>
<Checkbox
id={`${id}-name`}
checked={includeName}
onCheckedChange={setIncludeName}
/>
<Label htmlFor={`${id}-name`}>{t('Include name')}</Label>
</div>
<div className='flex items-center gap-2'>
<Checkbox
id={`${id}-quota`}
checked={includeQuota}
onCheckedChange={setIncludeQuota}
/>
<Label htmlFor={`${id}-quota`}>{t('Include quota')}</Label>
</div>
</div>
</div>
)}
</Dialog>
)
}
......@@ -50,7 +50,11 @@ import {
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import {
formatQuotaWithCurrency,
getCurrencyDisplay,
getCurrencyLabel,
} from '@/lib/currency'
import {
formatQuota,
getEditableQuotaStep,
......@@ -69,6 +73,10 @@ import {
transformRedemptionToFormDefaults,
} from '../lib'
import type { Redemption } from '../types'
import {
RedemptionsExportDialog,
type RedemptionExportData,
} from './redemptions-export-dialog'
import { useRedemptions } from './redemptions-provider'
type RedemptionsMutateDrawerProps = {
......@@ -87,6 +95,9 @@ export function RedemptionsMutateDrawer({
const redemptionId = currentRow?.id
const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [createdCodes, setCreatedCodes] = useState<RedemptionExportData | null>(
null
)
const [redemptionLoadState, setRedemptionLoadState] = useState<
'idle' | 'loading' | 'ready' | 'error'
>('idle')
......@@ -190,6 +201,15 @@ export function RedemptionsMutateDrawer({
})
: t(SUCCESS_MESSAGES.REDEMPTION_CREATED)
)
if (result.data?.length) {
setCreatedCodes({
keys: result.data,
name: basePayload.name,
quota: formatQuotaWithCurrency(basePayload.quota, {
abbreviate: false,
}),
})
}
onOpenChange(false)
triggerRefresh()
}
......@@ -232,196 +252,206 @@ export function RedemptionsMutateDrawer({
}
return (
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) {
form.reset()
}
}}
>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate
? t('Update Redemption Code')
: t('Create Redemption Code')}
</SheetTitle>
<SheetDescription>
{isUpdate
? t('Update the redemption code by providing necessary info.')
: t(
'Add new redemption code(s) by providing necessary info.'
)}{' '}
{t('Click save when you&apos;re done.')}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='redemption-form'
onSubmit={handleSubmit}
className={sideDrawerFormClassName()}
aria-busy={isLoadingRedemption}
>
<fieldset
disabled={!isUpdateReady || isSubmitting}
className='contents'
<>
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) {
form.reset()
}
}}
>
<SheetContent
className={sideDrawerContentClassName('sm:max-w-[600px]')}
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate
? t('Update Redemption Code')
: t('Create Redemption Code')}
</SheetTitle>
<SheetDescription>
{isUpdate
? t('Update the redemption code by providing necessary info.')
: t(
'Add new redemption code(s) by providing necessary info.'
)}{' '}
{t('Click save when you&apos;re done.')}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='redemption-form'
onSubmit={handleSubmit}
className={sideDrawerFormClassName()}
aria-busy={isLoadingRedemption}
>
<SideDrawerSection>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<FormDescription>
{t('Name for this redemption code (1-20 characters)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={quotaStep}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(
Number.parseFloat(e.target.value) || 0
)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='flex flex-col gap-2'>
<fieldset
disabled={!isUpdateReady || isSubmitting}
className='contents'
>
<SideDrawerSection>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1M')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 7, 0)}
>
{t('1W')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
{t('Name for this redemption code (1-20 characters)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='count'
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
max='100'
placeholder={t('Number of codes to create')}
step={quotaStep}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(
Number.parseInt(e.target.value, 10) || 1
Number.parseFloat(e.target.value) || 0
)
}
/>
</FormControl>
<FormDescription>
{t(
'Create multiple redemption codes at once (1-100)'
)}
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='flex flex-col gap-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1M')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 7, 0)}
>
{t('1W')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</SideDrawerSection>
</fieldset>
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
<Button
form='redemption-form'
type='submit'
disabled={isSubmitting || !isUpdateReady}
>
{submitButtonLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{!isUpdate && (
<FormField
control={form.control}
name='count'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
max='100'
placeholder={t('Number of codes to create')}
onChange={(e) =>
field.onChange(
Number.parseInt(e.target.value, 10) || 1
)
}
/>
</FormControl>
<FormDescription>
{t(
'Create multiple redemption codes at once (1-100)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</SideDrawerSection>
</fieldset>
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
<Button
form='redemption-form'
type='submit'
disabled={isSubmitting || !isUpdateReady}
>
{submitButtonLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{createdCodes && (
<RedemptionsExportDialog
data={createdCodes}
onClose={() => setCreatedCodes(null)}
/>
)}
</>
)
}
......@@ -133,6 +133,7 @@ export function RedemptionsTable() {
data: redemptions,
columns,
enableRowSelection: true,
getRowId: (row) => String(row.id),
columnFilters,
globalFilter,
pagination,
......
......@@ -617,3 +617,85 @@ it('distinguishes unchanged zero quota from missing or legacy balance metadata',
'legacy before → legacy after'
)
})
it.each([
[
'redemption.delete_batch',
'/api/redemption/batch',
{ count: 15 },
true,
'批量删除了 15 个兑换码',
],
[
'redemption.delete_batch',
'/api/redemption/batch',
{ count: 0 },
true,
'批量删除了 0 个兑换码',
],
[
'redemption.delete',
'/api/redemption/batch',
{},
true,
'批量删除兑换码(数量未记录)',
],
[
'redemption.delete_batch',
'/api/redemption/batch',
{},
false,
'批量删除兑换码失败',
],
['redemption.delete', '/api/redemption/:id', {}, true, '删除了一个兑换码'],
])(
'renders redemption audit %s at %s with recorded result %j',
async (action, route, params, success, summary) => {
const i18n = createInstance()
await i18n.init({ lng: 'zh', resources: { zh } })
const detail = buildAuditDetails(
{
...entry,
action,
route,
method: route.endsWith('/batch') ? 'POST' : 'DELETE',
success,
other: { op: { action, params } },
},
i18n.t
)
expect(detail.summary).toBe(summary)
}
)
it('shows the affected count separately from requested redemption IDs', async () => {
const i18n = createInstance()
await i18n.init({ lng: 'en', resources: {} })
const detail = buildAuditDetails(
{
...entry,
action: 'redemption.delete_batch',
route: '/api/redemption/batch',
method: 'POST',
other: {
op: {
action: 'redemption.delete_batch',
params: {
count: 2,
total: 4,
requested_redemption_ids: [11, 12, 11, 999],
},
},
},
},
i18n.t
)
expect(detail.summary).toBe('Batch deleted 2 redemption codes')
expect(detail.fields).toEqual(
expect.arrayContaining([
{ label: 'Count', value: 2 },
{ label: 'Total', value: 4 },
{ label: 'Requested redemption code IDs', value: [11, 12, 11, 999] },
])
)
})
......@@ -87,6 +87,8 @@ export function auditFieldLabel(key: string, t: TFunction): string {
return t('Count')
case 'total':
return t('Total')
case 'requested_redemption_ids':
return t('Requested redemption code IDs')
case 'requested_ids':
return t('Requested token IDs')
case 'returned_ids':
......@@ -369,8 +371,19 @@ export function buildAuditDetails(entry: AuditLog, t: TFunction) {
if (entry.category === 'security') fallback = t('Account security')
if (entry.category === 'access_token') fallback = t('Access Token')
const summary =
renderAuditContent({ op: { action, params: summaryParams } }, t) ||
(entry.content && entry.content !== action ? entry.content : fallback)
renderAuditContent(
{
op: { action, params: summaryParams },
audit_info: {
method: entry.method,
route: entry.route,
path: entry.route,
status: entry.status,
success: entry.success,
},
},
t
) || (entry.content && entry.content !== action ? entry.content : fallback)
const admin = isAuditDetailObject(metadata.admin_info)
? metadata.admin_info
: {}
......
......@@ -510,6 +510,24 @@ export function renderAuditContent(
): string | null {
const op = other?.op
if (!op?.action) return null
if (
op.action === 'redemption.delete_batch' ||
(op.action === 'redemption.delete' &&
other?.audit_info?.route === '/api/redemption/batch')
) {
if (other?.audit_info?.success === false) {
return t('Failed to batch delete redemption codes')
}
const count = op.params?.count
if (
typeof count === 'number' &&
Number.isSafeInteger(count) &&
count >= 0
) {
return t('Batch deleted {{count}} redemption codes', { count })
}
return t('Batch deleted redemption codes (count not recorded)')
}
const template = AUDIT_TEMPLATES[op.action]
if (!template) return null
const quotaOperation = buildQuotaAuditOperation(
......
......@@ -714,6 +714,8 @@
"Batch delete API tokens": "Batch delete API tokens",
"Batch delete failed": "Batch delete failed",
"Batch deleted {{count}} channels": "Batch deleted {{count}} channels",
"Batch deleted {{count}} redemption codes": "Batch deleted {{count}} redemption codes",
"Batch deleted redemption codes (count not recorded)": "Batch deleted redemption codes (count not recorded)",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed",
"Batch detection failed": "Batch detection failed",
"Batch disable failed": "Batch disable failed",
......@@ -1499,6 +1501,7 @@
"Delete (": "Delete (",
"Delete {{count}} API key(s)?": "Delete {{count}} API key(s)?",
"Delete {{count}} models?": "Delete {{count}} models?",
"Delete {{count}} redemption codes?": "Delete {{count}} redemption codes?",
"Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.": "Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.",
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Delete {{count}} stale instance records? Online instances will not be deleted.",
"Delete a runtime request header": "Delete a runtime request header",
......@@ -1540,6 +1543,7 @@
"Delete selected API keys": "Delete selected API keys",
"Delete selected channels": "Delete selected channels",
"Delete selected models": "Delete selected models",
"Delete selected redemption codes": "Delete selected redemption codes",
"Delete stale instance": "Delete stale instance",
"Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.": "Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.",
"Delete stale instances": "Delete stale instances",
......@@ -2081,6 +2085,7 @@
"Failed to adjust quota": "Failed to adjust quota",
"Failed to apply overwrite.": "Failed to apply overwrite.",
"Failed to apply vendor changes": "Failed to apply vendor changes",
"Failed to batch delete redemption codes": "Failed to batch delete redemption codes",
"Failed to bind email": "Failed to bind email",
"Failed to change password": "Failed to change password",
"Failed to check for updates": "Failed to check for updates",
......@@ -2102,6 +2107,7 @@
"Failed to create redemption code": "Failed to create redemption code",
"Failed to create user": "Failed to create user",
"Failed to delete {{count}} model(s)": "Failed to delete {{count}} model(s)",
"Failed to delete {{count}} redemption codes": "Failed to delete {{count}} redemption codes",
"Failed to delete account": "Failed to delete account",
"Failed to delete API key": "Failed to delete API key",
"Failed to delete API keys": "Failed to delete API keys",
......@@ -2652,6 +2658,8 @@
"Incidents": "Incidents",
"Include Group": "Include Group",
"Include Model": "Include Model",
"Include name": "Include name",
"Include quota": "Include quota",
"Include Rule Name": "Include Rule Name",
"Includes request rules": "Includes request rules",
"Includes tool-call surcharge": "Includes tool-call surcharge",
......@@ -4266,6 +4274,7 @@
"Redemption code(s) created successfully": "Redemption code(s) created successfully",
"Redemption Codes": "Redemption Codes",
"Redemption codes are disabled until the administrator confirms compliance terms.": "Redemption codes are disabled until the administrator confirms compliance terms.",
"Redemption codes created": "Redemption codes created",
"redemption codes.": "redemption codes.",
"Redemption failed": "Redemption failed",
"Redemption successful! Added: {{quota}}": "Redemption successful! Added: {{quota}}",
......@@ -4406,6 +4415,7 @@
"Requested items": "Requested items",
"Requested quota": "Requested quota",
"Requested quota: {{quota}}": "Requested quota: {{quota}}",
"Requested redemption code IDs": "Requested redemption code IDs",
"Requested token IDs": "Requested token IDs",
"Requested token IDs truncated": "Requested token IDs truncated",
"Requested: {{total}}": "Requested: {{total}}",
......@@ -4589,6 +4599,9 @@
"Save": "Save",
"Save & Submit": "Save & Submit",
"Save all settings": "Save all settings",
"Save as a file": "Save as a file",
"Save as Markdown": "Save as Markdown",
"Save as TXT": "Save as TXT",
"Save Backup Codes": "Save Backup Codes",
"Save changes": "Save changes",
"Save Changes": "Save Changes",
......@@ -4614,6 +4627,7 @@
"Save Preferences": "Save Preferences",
"Save preview": "Save preview",
"Save rate limits": "Save rate limits",
"Save redemption codes": "Save redemption codes",
"Save sensitive words": "Save sensitive words",
"Save Settings": "Save Settings",
"Save sidebar modules": "Save sidebar modules",
......@@ -5056,6 +5070,7 @@
"Successfully deleted {{count}} API key(s)": "Successfully deleted {{count}} API key(s)",
"Successfully deleted {{count}} invalid redemption codes": "Successfully deleted {{count}} invalid redemption codes",
"Successfully deleted {{count}} model(s)": "Successfully deleted {{count}} model(s)",
"Successfully deleted {{count}} redemption codes": "Successfully deleted {{count}} redemption codes",
"Successfully disabled {{count}} model(s)": "Successfully disabled {{count}} model(s)",
"Successfully enabled {{count}} model(s)": "Successfully enabled {{count}} model(s)",
"Suffix": "Suffix",
......
......@@ -714,6 +714,8 @@
"Batch delete API tokens": "Supprimer des jetons API par lot",
"Batch delete failed": "Échec de la suppression par lots",
"Batch deleted {{count}} channels": "{{count}} canaux supprimés par lot",
"Batch deleted {{count}} redemption codes": "{{count}} codes d’échange supprimés en lot",
"Batch deleted redemption codes (count not recorded)": "Codes d’échange supprimés en lot (nombre non enregistré)",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Détection par lots terminée : {{channels}} canaux, {{add}} à ajouter, {{remove}} à supprimer, {{fails}} échoués",
"Batch detection failed": "Échec de la détection par lot",
"Batch disable failed": "Échec de la désactivation par lots",
......@@ -1499,6 +1501,7 @@
"Delete (": "Supprimer (",
"Delete {{count}} API key(s)?": "Supprimer {{count}} clé(s) API ?",
"Delete {{count}} models?": "Supprimer {{count}} modèles ?",
"Delete {{count}} redemption codes?": "Supprimer {{count}} codes d’échange ?",
"Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.": "Supprimer les {{count}} fournisseurs sélectionnés ? Ceux ayant des modèles associés ne peuvent pas être supprimés.",
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Supprimer {{count}} enregistrement(s) d'instance expirée ? Les instances en ligne ne seront pas supprimées.",
"Delete a runtime request header": "Supprimer un en-tête de requête à l'exécution",
......@@ -1540,6 +1543,7 @@
"Delete selected API keys": "Supprimer les clés API sélectionnées",
"Delete selected channels": "Supprimer les canaux sélectionnés",
"Delete selected models": "Supprimer les modèles sélectionnés",
"Delete selected redemption codes": "Supprimer les codes sélectionnés",
"Delete stale instance": "Supprimer l'instance expirée",
"Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.": "Supprimer l'instance expirée \"{{name}}\" ? Si elle a de nouveau signalé son état, elle ne sera pas supprimée.",
"Delete stale instances": "Supprimer les instances expirées",
......@@ -2081,6 +2085,7 @@
"Failed to adjust quota": "Échec de l'ajustement du quota",
"Failed to apply overwrite.": "Échec de l'application de l'écrasement.",
"Failed to apply vendor changes": "Échec de l’application des modifications",
"Failed to batch delete redemption codes": "Échec de la suppression des codes d’échange en lot",
"Failed to bind email": "Échec de la liaison de l'e-mail",
"Failed to change password": "Échec du changement de mot de passe",
"Failed to check for updates": "Échec de la vérification des mises à jour",
......@@ -2102,6 +2107,7 @@
"Failed to create redemption code": "Échec de la création du code d'échange",
"Failed to create user": "Échec de la création de l'utilisateur",
"Failed to delete {{count}} model(s)": "Échec de la suppression de {{count}} modèle(s)",
"Failed to delete {{count}} redemption codes": "Échec de la suppression de {{count}} codes d’échange",
"Failed to delete account": "Échec de la suppression du compte",
"Failed to delete API key": "Échec de la suppression de la clé API",
"Failed to delete API keys": "Échec de la suppression des Clés API",
......@@ -2652,6 +2658,8 @@
"Incidents": "Incidents",
"Include Group": "Inclure le groupe",
"Include Model": "Inclure le modèle",
"Include name": "Inclure le nom",
"Include quota": "Inclure le quota",
"Include Rule Name": "Inclure le nom de la règle",
"Includes request rules": "Inclut des règles de requête",
"Includes tool-call surcharge": "Inclut un supplément pour appel d’outil",
......@@ -4266,6 +4274,7 @@
"Redemption code(s) created successfully": "Code(s) d'échange créé(s) avec succès",
"Redemption Codes": "Codes d'échange",
"Redemption codes are disabled until the administrator confirms compliance terms.": "Les codes de兑换 sont désactivés jusqu’à ce que l’administrateur confirme les conditions de conformité.",
"Redemption codes created": "Codes d’échange créés",
"redemption codes.": "codes de rachat.",
"Redemption failed": "Rédemption échouée",
"Redemption successful! Added: {{quota}}": "Échange réussi ! Ajouté : {{quota}}",
......@@ -4406,6 +4415,7 @@
"Requested items": "Éléments demandés",
"Requested quota": "Montant demandé",
"Requested quota: {{quota}}": "Montant demandé : {{quota}}",
"Requested redemption code IDs": "ID des codes d’échange à supprimer",
"Requested token IDs": "ID des jetons demandés",
"Requested token IDs truncated": "Liste des ID demandés tronquée",
"Requested: {{total}}": "Demandés : {{total}}",
......@@ -4589,6 +4599,9 @@
"Save": "Enregistrer",
"Save & Submit": "Enregistrer et envoyer",
"Save all settings": "Enregistrer tous les paramètres",
"Save as a file": "Enregistrer dans un fichier",
"Save as Markdown": "Enregistrer en Markdown",
"Save as TXT": "Enregistrer en TXT",
"Save Backup Codes": "Sauvegarder les codes de secours",
"Save changes": "Enregistrer les modifications",
"Save Changes": "Enregistrer les modifications",
......@@ -4614,6 +4627,7 @@
"Save Preferences": "Enregistrer les préférences",
"Save preview": "Aperçu de l’enregistrement",
"Save rate limits": "Enregistrer les limites de débit",
"Save redemption codes": "Enregistrer les codes d’échange",
"Save sensitive words": "Enregistrer les mots sensibles",
"Save Settings": "Enregistrer les paramètres",
"Save sidebar modules": "Enregistrer les modules de la barre latérale",
......@@ -5056,6 +5070,7 @@
"Successfully deleted {{count}} API key(s)": "{{count}} clé(s) API supprimée(s) avec succès",
"Successfully deleted {{count}} invalid redemption codes": "{{count}} code(s) d'échange invalide(s) supprimé(s) avec succès",
"Successfully deleted {{count}} model(s)": "{{count}} modèle(s) supprimé(s) avec succès",
"Successfully deleted {{count}} redemption codes": "{{count}} codes d’échange supprimés",
"Successfully disabled {{count}} model(s)": "{{count}} modèle(s) désactivé(s) avec succès",
"Successfully enabled {{count}} model(s)": "{{count}} modèle(s) activé(s) avec succès",
"Suffix": "Suffixe",
......
......@@ -714,6 +714,8 @@
"Batch delete API tokens": "API トークンを一括削除",
"Batch delete failed": "一括削除に失敗しました",
"Batch deleted {{count}} channels": "{{count}} 件のチャネルを一括削除しました",
"Batch deleted {{count}} redemption codes": "{{count}} 件の引き換えコードを一括削除しました",
"Batch deleted redemption codes (count not recorded)": "引き換えコードを一括削除(件数未記録)",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "一括検出完了:{{channels}} チャネル、{{add}} 個追加、{{remove}} 個削除、{{fails}} 個失敗",
"Batch detection failed": "一括検出に失敗しました",
"Batch disable failed": "一括無効化に失敗しました",
......@@ -1499,6 +1501,7 @@
"Delete (": "削除 (",
"Delete {{count}} API key(s)?": "{{count}}個のAPIキーを削除しますか?",
"Delete {{count}} models?": "{{count}} 件のモデルを削除しますか?",
"Delete {{count}} redemption codes?": "{{count}} 件の引き換えコードを削除しますか?",
"Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.": "選択した {{count}} 件のプロバイダーを削除しますか?関連モデルがある場合は削除できません。",
"Delete {{count}} stale instance records? Online instances will not be deleted.": "期限切れインスタンスレコードを {{count}} 件削除しますか?オンラインのインスタンスは削除されません。",
"Delete a runtime request header": "ランタイムリクエストヘッダーを削除",
......@@ -1540,6 +1543,7 @@
"Delete selected API keys": "選択したAPIキーを削除",
"Delete selected channels": "選択したチャネルを削除",
"Delete selected models": "選択したモデルを削除",
"Delete selected redemption codes": "選択した引き換えコードを削除",
"Delete stale instance": "期限切れインスタンスを削除",
"Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.": "期限切れインスタンス \"{{name}}\" を削除しますか?再度報告されている場合は削除されません。",
"Delete stale instances": "期限切れインスタンスを削除",
......@@ -2081,6 +2085,7 @@
"Failed to adjust quota": "クォータの調整に失敗しました",
"Failed to apply overwrite.": "オーバーライトの適用に失敗しました。",
"Failed to apply vendor changes": "プロバイダーの変更を適用できませんでした",
"Failed to batch delete redemption codes": "引き換えコードの一括削除に失敗しました",
"Failed to bind email": "メールのバインドに失敗しました",
"Failed to change password": "パスワードの変更に失敗しました",
"Failed to check for updates": "更新の確認に失敗しました",
......@@ -2102,6 +2107,7 @@
"Failed to create redemption code": "引き換えコードの作成に失敗しました",
"Failed to create user": "ユーザーの作成に失敗しました",
"Failed to delete {{count}} model(s)": "{{count}} 個のモデルの削除に失敗しました",
"Failed to delete {{count}} redemption codes": "{{count}} 件の引き換えコードを削除できませんでした",
"Failed to delete account": "アカウントの削除に失敗しました",
"Failed to delete API key": "APIキーの削除に失敗しました",
"Failed to delete API keys": "APIキーの削除に失敗しました",
......@@ -2652,6 +2658,8 @@
"Incidents": "インシデント",
"Include Group": "グループを含む",
"Include Model": "モデルを含む",
"Include name": "名前を含める",
"Include quota": "クォータを含める",
"Include Rule Name": "ルール名を含む",
"Includes request rules": "リクエストルールを含む",
"Includes tool-call surcharge": "ツール呼び出しの追加料金を含む",
......@@ -4266,6 +4274,7 @@
"Redemption code(s) created successfully": "引き換えコードが正常に作成されました",
"Redemption Codes": "引き換えコード",
"Redemption codes are disabled until the administrator confirms compliance terms.": "管理者がコンプライアンス条件を確認するまで、引換コードは無効です。",
"Redemption codes created": "引き換えコードを作成しました",
"redemption codes.": "引き換えコード。",
"Redemption failed": "交換に失敗しました",
"Redemption successful! Added: {{quota}}": "引き換え成功!追加:{{quota}}",
......@@ -4406,6 +4415,7 @@
"Requested items": "リクエスト件数",
"Requested quota": "リクエスト額",
"Requested quota: {{quota}}": "リクエスト額:{{quota}}",
"Requested redemption code IDs": "削除を要求した引き換えコードの ID",
"Requested token IDs": "リクエストしたトークン ID",
"Requested token IDs truncated": "リクエストしたトークン ID の一覧は省略されています",
"Requested: {{total}}": "リクエスト:{{total}} 件",
......@@ -4589,6 +4599,9 @@
"Save": "保存",
"Save & Submit": "保存して送信",
"Save all settings": "すべての設定を保存",
"Save as a file": "ファイルに保存",
"Save as Markdown": "Markdown で保存",
"Save as TXT": "TXT で保存",
"Save Backup Codes": "バックアップコードを保存",
"Save changes": "変更を保存",
"Save Changes": "変更を保存",
......@@ -4614,6 +4627,7 @@
"Save Preferences": "設定を保存",
"Save preview": "保存プレビュー",
"Save rate limits": "レート制限を保存",
"Save redemption codes": "引き換えコードを保存",
"Save sensitive words": "敏感な言葉を保存",
"Save Settings": "設定を保存",
"Save sidebar modules": "サイドバーモジュールを保存",
......@@ -5056,6 +5070,7 @@
"Successfully deleted {{count}} API key(s)": "{{count}}個のAPIキーが正常に削除されました",
"Successfully deleted {{count}} invalid redemption codes": "{{count}} 件の無効な引き換えコードを削除しました",
"Successfully deleted {{count}} model(s)": "{{count}} 個のモデルを削除しました",
"Successfully deleted {{count}} redemption codes": "{{count}} 件の引き換えコードを削除しました",
"Successfully disabled {{count}} model(s)": "{{count}} 個のモデルを無効にしました",
"Successfully enabled {{count}} model(s)": "{{count}} 個のモデルを有効にしました",
"Suffix": "サフィックス",
......
......@@ -714,6 +714,8 @@
"Batch delete API tokens": "Массовое удаление токенов API",
"Batch delete failed": "Пакетное удаление не удалось",
"Batch deleted {{count}} channels": "Пакетно удалено каналов: {{count}}",
"Batch deleted {{count}} redemption codes": "Массово удалено кодов погашения: {{count}}",
"Batch deleted redemption codes (count not recorded)": "Массовое удаление кодов погашения (количество не записано)",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Пакетное обнаружение завершено: {{channels}} каналов, {{add}} для добавления, {{remove}} для удаления, {{fails}} ошибок",
"Batch detection failed": "Пакетное обнаружение не удалось",
"Batch disable failed": "Пакетное отключение не удалось",
......@@ -1499,6 +1501,7 @@
"Delete (": "Удалить (",
"Delete {{count}} API key(s)?": "Удалить {{count}} API-ключ(а/ей)?",
"Delete {{count}} models?": "Удалить {{count}} моделей?",
"Delete {{count}} redemption codes?": "Удалить коды погашения ({{count}})?",
"Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.": "Удалить выбранные записи поставщиков ({{count}})? Поставщиков со связанными моделями удалить нельзя.",
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Удалить {{count}} записей устаревших экземпляров? Онлайн-экземпляры не будут удалены.",
"Delete a runtime request header": "Удалить заголовок запроса во время выполнения",
......@@ -1540,6 +1543,7 @@
"Delete selected API keys": "Удалить выбранные ключи API",
"Delete selected channels": "Удалить выбранные каналы",
"Delete selected models": "Удалить выбранные модели",
"Delete selected redemption codes": "Удалить выбранные коды погашения",
"Delete stale instance": "Удалить устаревший экземпляр",
"Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.": "Удалить устаревший экземпляр \"{{name}}\"? Если он снова отправил отчет, он не будет удален.",
"Delete stale instances": "Удалить устаревшие экземпляры",
......@@ -2081,6 +2085,7 @@
"Failed to adjust quota": "Не удалось изменить квоту",
"Failed to apply overwrite.": "Не удалось применить перезапись.",
"Failed to apply vendor changes": "Не удалось применить изменения поставщиков",
"Failed to batch delete redemption codes": "Не удалось массово удалить коды погашения",
"Failed to bind email": "Не удалось привязать email",
"Failed to change password": "Не удалось изменить пароль",
"Failed to check for updates": "Не удалось проверить обновления",
......@@ -2102,6 +2107,7 @@
"Failed to create redemption code": "Не удалось создать код активации",
"Failed to create user": "Не удалось создать пользователя",
"Failed to delete {{count}} model(s)": "Не удалось удалить {{count}} моделей",
"Failed to delete {{count}} redemption codes": "Не удалось удалить коды погашения: {{count}}",
"Failed to delete account": "Не удалось удалить аккаунт",
"Failed to delete API key": "Не удалось удалить API ключ",
"Failed to delete API keys": "Не удалось удалить API ключи",
......@@ -2652,6 +2658,8 @@
"Incidents": "Инциденты",
"Include Group": "Включить группу",
"Include Model": "Включить модель",
"Include name": "Включить название",
"Include quota": "Включить квоту",
"Include Rule Name": "Включить имя правила",
"Includes request rules": "Включает правила запросов",
"Includes tool-call surcharge": "Включает доплату за вызов инструмента",
......@@ -4266,6 +4274,7 @@
"Redemption code(s) created successfully": "Код(ы) активации успешно создан(ы)",
"Redemption Codes": "Коды активации",
"Redemption codes are disabled until the administrator confirms compliance terms.": "Коды пополнения отключены, пока администратор не подтвердит условия соответствия.",
"Redemption codes created": "Коды погашения созданы",
"redemption codes.": "коды активации.",
"Redemption failed": "Погашение не удалось",
"Redemption successful! Added: {{quota}}": "Погашение успешно! Добавлено: {{quota}}",
......@@ -4406,6 +4415,7 @@
"Requested items": "Запрошенные элементы",
"Requested quota": "Запрошенная сумма",
"Requested quota: {{quota}}": "Запрошенная сумма: {{quota}}",
"Requested redemption code IDs": "ID кодов погашения, запрошенных к удалению",
"Requested token IDs": "Запрошенные ID токенов",
"Requested token IDs truncated": "Список запрошенных ID токенов усечён",
"Requested: {{total}}": "Запрошено: {{total}}",
......@@ -4589,6 +4599,9 @@
"Save": "Сохранить",
"Save & Submit": "Сохранить и отправить",
"Save all settings": "Сохранить все настройки",
"Save as a file": "Сохранить в файл",
"Save as Markdown": "Сохранить в Markdown",
"Save as TXT": "Сохранить в TXT",
"Save Backup Codes": "Сохранить резервные коды",
"Save changes": "Сохранить изменения",
"Save Changes": "Сохранить изменения",
......@@ -4614,6 +4627,7 @@
"Save Preferences": "Сохранить настройки",
"Save preview": "Предпросмотр сохранения",
"Save rate limits": "Сохранить лимиты скорости",
"Save redemption codes": "Сохранить коды погашения",
"Save sensitive words": "Сохранить чувствительные слова",
"Save Settings": "Сохранить настройки",
"Save sidebar modules": "Сохранить модули боковой панели",
......@@ -5056,6 +5070,7 @@
"Successfully deleted {{count}} API key(s)": "Успешно удалено {{count}} API-ключ(а/ей)",
"Successfully deleted {{count}} invalid redemption codes": "Успешно удалено {{count}} недействительных кодов активации",
"Successfully deleted {{count}} model(s)": "Успешно удалено {{count}} моделей",
"Successfully deleted {{count}} redemption codes": "Коды погашения удалены: {{count}}",
"Successfully disabled {{count}} model(s)": "Успешно отключено {{count}} моделей",
"Successfully enabled {{count}} model(s)": "Успешно включено {{count}} моделей",
"Suffix": "Суффикс",
......
......@@ -714,6 +714,8 @@
"Batch delete API tokens": "Xóa hàng loạt mã thông báo API",
"Batch delete failed": "Xóa hàng loạt thất bại",
"Batch deleted {{count}} channels": "Đã xóa hàng loạt {{count}} kênh",
"Batch deleted {{count}} redemption codes": "Đã xóa hàng loạt {{count}} mã quy đổi",
"Batch deleted redemption codes (count not recorded)": "Đã xóa hàng loạt mã quy đổi (không ghi lại số lượng)",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "Phát hiện hàng loạt hoàn tất: {{channels}} kênh, {{add}} để thêm, {{remove}} để xóa, {{fails}} thất bại",
"Batch detection failed": "Phát hiện hàng loạt thất bại",
"Batch disable failed": "Vô hiệu hóa hàng loạt thất bại",
......@@ -1499,6 +1501,7 @@
"Delete (": "Xóa (",
"Delete {{count}} API key(s)?": "Xóa {{count}} khóa API?",
"Delete {{count}} models?": "Xóa {{count}} mô hình?",
"Delete {{count}} redemption codes?": "Xóa {{count}} mã quy đổi?",
"Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.": "Xóa {{count}} bản ghi nhà cung cấp đã chọn? Không thể xóa nhà cung cấp còn mô hình liên kết.",
"Delete {{count}} stale instance records? Online instances will not be deleted.": "Xóa {{count}} bản ghi phiên bản mất kết nối? Các phiên bản đang trực tuyến sẽ không bị xóa.",
"Delete a runtime request header": "Xóa header yêu cầu runtime",
......@@ -1540,6 +1543,7 @@
"Delete selected API keys": "Xóa các khóa API đã chọn",
"Delete selected channels": "Xóa các kênh đã chọn",
"Delete selected models": "Xóa các mô hình đã chọn",
"Delete selected redemption codes": "Xóa mã quy đổi đã chọn",
"Delete stale instance": "Xóa phiên bản mất kết nối",
"Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.": "Xóa phiên bản mất kết nối \"{{name}}\"? Nếu phiên bản này đã báo cáo lại, nó sẽ không bị xóa.",
"Delete stale instances": "Xóa các phiên bản mất kết nối",
......@@ -2081,6 +2085,7 @@
"Failed to adjust quota": "Không thể điều chỉnh hạn mức",
"Failed to apply overwrite.": "Không thể áp dụng ghi đè.",
"Failed to apply vendor changes": "Không thể áp dụng thay đổi nhà cung cấp",
"Failed to batch delete redemption codes": "Không thể xóa hàng loạt mã quy đổi",
"Failed to bind email": "Không thể liên kết email",
"Failed to change password": "Không thể thay đổi mật khẩu",
"Failed to check for updates": "Không thể kiểm tra cập nhật",
......@@ -2102,6 +2107,7 @@
"Failed to create redemption code": "Không thể tạo mã đổi thưởng",
"Failed to create user": "Tạo người dùng thất bại",
"Failed to delete {{count}} model(s)": "Không thể xóa {{count}} mô hình",
"Failed to delete {{count}} redemption codes": "Không thể xóa {{count}} mã quy đổi",
"Failed to delete account": "Không thể xóa tài khoản",
"Failed to delete API key": "Xóa API key thất bại",
"Failed to delete API keys": "Không thể xóa khóa API",
......@@ -2652,6 +2658,8 @@
"Incidents": "Sự cố",
"Include Group": "Bao gồm nhóm",
"Include Model": "Bao gồm mô hình",
"Include name": "Bao gồm tên",
"Include quota": "Bao gồm hạn mức",
"Include Rule Name": "Bao gồm tên quy tắc",
"Includes request rules": "Bao gồm quy tắc yêu cầu",
"Includes tool-call surcharge": "Bao gồm phụ phí gọi công cụ",
......@@ -4266,6 +4274,7 @@
"Redemption code(s) created successfully": "Mã đổi thưởng đã tạo thành công",
"Redemption Codes": "Mã đổi thưởng",
"Redemption codes are disabled until the administrator confirms compliance terms.": "Mã đổi thưởng bị tắt cho đến khi quản trị viên xác nhận điều khoản tuân thủ.",
"Redemption codes created": "Đã tạo mã quy đổi",
"redemption codes.": "Mã đổi thưởng",
"Redemption failed": "Đổi thưởng thất bại",
"Redemption successful! Added: {{quota}}": "Đổi mã thành công! Đã thêm: {{quota}}",
......@@ -4406,6 +4415,7 @@
"Requested items": "Số mục yêu cầu",
"Requested quota": "Số tiền yêu cầu",
"Requested quota: {{quota}}": "Số tiền yêu cầu: {{quota}}",
"Requested redemption code IDs": "ID mã quy đổi được yêu cầu xóa",
"Requested token IDs": "ID mã thông báo được yêu cầu",
"Requested token IDs truncated": "Danh sách ID mã thông báo yêu cầu đã được cắt ngắn",
"Requested: {{total}}": "Yêu cầu: {{total}}",
......@@ -4589,6 +4599,9 @@
"Save": "Lưu",
"Save & Submit": "Lưu và gửi",
"Save all settings": "Lưu tất cả cài đặt",
"Save as a file": "Lưu thành tệp",
"Save as Markdown": "Lưu dạng Markdown",
"Save as TXT": "Lưu dạng TXT",
"Save Backup Codes": "Lưu mã dự phòng",
"Save changes": "Lưu thay đổi",
"Save Changes": "Lưu Thay đổi",
......@@ -4614,6 +4627,7 @@
"Save Preferences": "Lưu tùy chọn",
"Save preview": "Xem trước lưu",
"Save rate limits": "Lưu giới hạn tốc độ",
"Save redemption codes": "Lưu mã quy đổi",
"Save sensitive words": "Lưu từ nhạy cảm",
"Save Settings": "Lưu Cài đặt",
"Save sidebar modules": "Lưu các mô-đun thanh bên",
......@@ -5056,6 +5070,7 @@
"Successfully deleted {{count}} API key(s)": "Đã xóa thành công {{count}} khóa API",
"Successfully deleted {{count}} invalid redemption codes": "Đã xóa thành công {{count}} mã đổi thưởng không hợp lệ",
"Successfully deleted {{count}} model(s)": "Đã xóa thành công {{count}} mô hình",
"Successfully deleted {{count}} redemption codes": "Đã xóa {{count}} mã quy đổi",
"Successfully disabled {{count}} model(s)": "Đã tắt thành công {{count}} mô hình",
"Successfully enabled {{count}} model(s)": "Đã bật thành công {{count}} mô hình",
"Suffix": "Hậu tố",
......
......@@ -714,6 +714,8 @@
"Batch delete API tokens": "批次刪除 API 權杖",
"Batch delete failed": "大量刪除失敗",
"Batch deleted {{count}} channels": "大量刪除 {{count}} 個渠道",
"Batch deleted {{count}} redemption codes": "批次刪除了 {{count}} 個兌換碼",
"Batch deleted redemption codes (count not recorded)": "批次刪除兌換碼(數量未記錄)",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "大量檢測完成:渠道 {{channels}} 個,新增 {{add}} 個,刪除 {{remove}} 個,失敗 {{fails}} 個",
"Batch detection failed": "大量檢測失敗",
"Batch disable failed": "大量停用失敗",
......@@ -1499,6 +1501,7 @@
"Delete (": "刪除 (",
"Delete {{count}} API key(s)?": "刪除 {{count}} 個 API 金鑰?",
"Delete {{count}} models?": "刪除 {{count}} 個模型?",
"Delete {{count}} redemption codes?": "刪除 {{count}} 個兌換碼?",
"Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.": "刪除選取的 {{count}} 筆供應商記錄?有關聯模型的供應商無法刪除。",
"Delete {{count}} stale instance records? Online instances will not be deleted.": "刪除 {{count}} 筆失聯實例記錄?線上實例不會被刪除。",
"Delete a runtime request header": "刪除運行期請求頭",
......@@ -1540,6 +1543,7 @@
"Delete selected API keys": "刪除選定的 API 金鑰",
"Delete selected channels": "刪除所選渠道",
"Delete selected models": "刪除選定的模型",
"Delete selected redemption codes": "刪除選取的兌換碼",
"Delete stale instance": "刪除失聯實例",
"Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.": "刪除失聯實例「{{name}}」?如果它已重新上報,將不會被刪除。",
"Delete stale instances": "刪除失聯實例",
......@@ -2081,6 +2085,7 @@
"Failed to adjust quota": "調整額度失敗",
"Failed to apply overwrite.": "套用覆蓋失敗。",
"Failed to apply vendor changes": "套用供應商變更失敗",
"Failed to batch delete redemption codes": "批次刪除兌換碼失敗",
"Failed to bind email": "連結電郵失敗",
"Failed to change password": "修改密碼失敗",
"Failed to check for updates": "檢查更新失敗",
......@@ -2102,6 +2107,7 @@
"Failed to create redemption code": "建立兌換碼失敗",
"Failed to create user": "建立用戶失敗",
"Failed to delete {{count}} model(s)": "刪除 {{count}} 個模型失敗",
"Failed to delete {{count}} redemption codes": "{{count}} 個兌換碼刪除失敗",
"Failed to delete account": "刪除賬號失敗",
"Failed to delete API key": "刪除API金鑰失敗",
"Failed to delete API keys": "刪除API金鑰失敗",
......@@ -2652,6 +2658,8 @@
"Incidents": "事件",
"Include Group": "包含分組",
"Include Model": "包含模型",
"Include name": "包含名稱",
"Include quota": "包含額度",
"Include Rule Name": "包含規則名",
"Includes request rules": "包含請求規則",
"Includes tool-call surcharge": "包含工具呼叫附加費",
......@@ -4266,6 +4274,7 @@
"Redemption code(s) created successfully": "兌換碼建立成功",
"Redemption Codes": "兌換碼",
"Redemption codes are disabled until the administrator confirms compliance terms.": "管理員確認合規條款之前,兌換碼功能不可用。",
"Redemption codes created": "兌換碼建立完成",
"redemption codes.": "兌換碼。",
"Redemption failed": "兌換失敗",
"Redemption successful! Added: {{quota}}": "兌換成功!已新增:{{quota}}",
......@@ -4406,6 +4415,7 @@
"Requested items": "請求項數",
"Requested quota": "請求金額",
"Requested quota: {{quota}}": "請求金額:{{quota}}",
"Requested redemption code IDs": "請求刪除的兌換碼 ID",
"Requested token IDs": "請求的權杖 ID",
"Requested token IDs truncated": "請求的權杖 ID 已截斷",
"Requested: {{total}}": "請求 {{total}} 項",
......@@ -4589,6 +4599,9 @@
"Save": "儲存",
"Save & Submit": "儲存並提交",
"Save all settings": "儲存所有設定",
"Save as a file": "儲存為檔案",
"Save as Markdown": "儲存為 Markdown",
"Save as TXT": "儲存為 TXT",
"Save Backup Codes": "儲存備份代碼",
"Save changes": "儲存變更",
"Save Changes": "儲存變更",
......@@ -4614,6 +4627,7 @@
"Save Preferences": "儲存偏好設定",
"Save preview": "儲存預覽",
"Save rate limits": "儲存速率限制",
"Save redemption codes": "儲存兌換碼",
"Save sensitive words": "儲存敏感詞",
"Save Settings": "儲存設定",
"Save sidebar modules": "儲存側邊欄模組",
......@@ -5056,6 +5070,7 @@
"Successfully deleted {{count}} API key(s)": "成功刪除了 {{count}} 個 API 金鑰",
"Successfully deleted {{count}} invalid redemption codes": "已成功刪除 {{count}} 個無效兌換碼",
"Successfully deleted {{count}} model(s)": "成功刪除 {{count}} 個模型",
"Successfully deleted {{count}} redemption codes": "成功刪除 {{count}} 個兌換碼",
"Successfully disabled {{count}} model(s)": "成功停用 {{count}} 個模型",
"Successfully enabled {{count}} model(s)": "成功啟用 {{count}} 個模型",
"Suffix": "後綴",
......
......@@ -714,6 +714,8 @@
"Batch delete API tokens": "批量删除 API 令牌",
"Batch delete failed": "批量删除失败",
"Batch deleted {{count}} channels": "批量删除 {{count}} 个渠道",
"Batch deleted {{count}} redemption codes": "批量删除了 {{count}} 个兑换码",
"Batch deleted redemption codes (count not recorded)": "批量删除兑换码(数量未记录)",
"Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed": "批量检测完成:渠道 {{channels}} 个,新增 {{add}} 个,删除 {{remove}} 个,失败 {{fails}} 个",
"Batch detection failed": "批量检测失败",
"Batch disable failed": "批量禁用失败",
......@@ -1499,6 +1501,7 @@
"Delete (": "删除 (",
"Delete {{count}} API key(s)?": "删除 {{count}} 个 API 密钥?",
"Delete {{count}} models?": "删除 {{count}} 个模型?",
"Delete {{count}} redemption codes?": "删除 {{count}} 个兑换码?",
"Delete {{count}} selected vendor records? Vendors with linked models cannot be deleted.": "删除选中的 {{count}} 条供应商记录?存在关联模型的供应商无法删除。",
"Delete {{count}} stale instance records? Online instances will not be deleted.": "删除 {{count}} 条失联实例记录?在线实例不会被删除。",
"Delete a runtime request header": "删除运行期请求头",
......@@ -1540,6 +1543,7 @@
"Delete selected API keys": "删除选定的 API 密钥",
"Delete selected channels": "删除所选渠道",
"Delete selected models": "删除选定的模型",
"Delete selected redemption codes": "删除选中的兑换码",
"Delete stale instance": "删除失联实例",
"Delete stale instance \"{{name}}\"? If it has reported again, it will not be deleted.": "删除失联实例 \"{{name}}\"?如果它已经重新上报,将不会被删除。",
"Delete stale instances": "删除失联实例",
......@@ -2081,6 +2085,7 @@
"Failed to adjust quota": "调整额度失败",
"Failed to apply overwrite.": "应用覆盖失败。",
"Failed to apply vendor changes": "应用供应商更改失败",
"Failed to batch delete redemption codes": "批量删除兑换码失败",
"Failed to bind email": "绑定邮箱失败",
"Failed to change password": "修改密码失败",
"Failed to check for updates": "检查更新失败",
......@@ -2102,6 +2107,7 @@
"Failed to create redemption code": "创建兑换码失败",
"Failed to create user": "创建用户失败",
"Failed to delete {{count}} model(s)": "删除 {{count}} 个模型失败",
"Failed to delete {{count}} redemption codes": "{{count}} 个兑换码删除失败",
"Failed to delete account": "删除账号失败",
"Failed to delete API key": "删除API密钥失败",
"Failed to delete API keys": "删除API密钥失败",
......@@ -2652,6 +2658,8 @@
"Incidents": "事件",
"Include Group": "包含分组",
"Include Model": "包含模型",
"Include name": "包含名称",
"Include quota": "包含额度",
"Include Rule Name": "包含规则名",
"Includes request rules": "包含请求规则",
"Includes tool-call surcharge": "包含工具调用附加费",
......@@ -4266,6 +4274,7 @@
"Redemption code(s) created successfully": "兑换码创建成功",
"Redemption Codes": "兑换码",
"Redemption codes are disabled until the administrator confirms compliance terms.": "管理员确认合规条款之前,兑换码功能不可用。",
"Redemption codes created": "兑换码创建完成",
"redemption codes.": "兑换码。",
"Redemption failed": "兑换失败",
"Redemption successful! Added: {{quota}}": "兑换成功!已添加:{{quota}}",
......@@ -4406,6 +4415,7 @@
"Requested items": "请求项数",
"Requested quota": "请求数额",
"Requested quota: {{quota}}": "请求数额:{{quota}}",
"Requested redemption code IDs": "请求删除的兑换码 ID",
"Requested token IDs": "请求的令牌 ID",
"Requested token IDs truncated": "请求的令牌 ID 已截断",
"Requested: {{total}}": "请求 {{total}} 项",
......@@ -4589,6 +4599,9 @@
"Save": "保存",
"Save & Submit": "保存并提交",
"Save all settings": "保存所有设置",
"Save as a file": "保存为文件",
"Save as Markdown": "保存为 Markdown",
"Save as TXT": "保存为 TXT",
"Save Backup Codes": "保存备份代码",
"Save changes": "保存更改",
"Save Changes": "保存更改",
......@@ -4614,6 +4627,7 @@
"Save Preferences": "保存偏好设置",
"Save preview": "保存预览",
"Save rate limits": "保存速率限制",
"Save redemption codes": "保存兑换码",
"Save sensitive words": "保存敏感词",
"Save Settings": "保存设置",
"Save sidebar modules": "保存侧边栏模块",
......@@ -5056,6 +5070,7 @@
"Successfully deleted {{count}} API key(s)": "成功删除了 {{count}} 个 API 密钥",
"Successfully deleted {{count}} invalid redemption codes": "已成功删除 {{count}} 个无效兑换码",
"Successfully deleted {{count}} model(s)": "成功删除 {{count}} 个模型",
"Successfully deleted {{count}} redemption codes": "成功删除 {{count}} 个兑换码",
"Successfully disabled {{count}} model(s)": "成功禁用 {{count}} 个模型",
"Successfully enabled {{count}} model(s)": "成功启用 {{count}} 个模型",
"Suffix": "后缀",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment