diff --git a/controller/audit.go b/controller/audit.go index b87cb47cf..d9b2b85d5 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -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}", diff --git a/controller/redemption.go b/controller/redemption.go index 86289f8a2..a6637610e 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -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) +} diff --git a/controller/redemption_batch_test.go b/controller/redemption_batch_test.go new file mode 100644 index 000000000..8ff0d55c3 --- /dev/null +++ b/controller/redemption_batch_test.go @@ -0,0 +1,201 @@ +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, ¶ms)) + 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) + }) + } +} diff --git a/middleware/audit.go b/middleware/audit.go index dab54a4a0..626e472c9 100644 --- a/middleware/audit.go +++ b/middleware/audit.go @@ -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", diff --git a/model/redemption.go b/model/redemption.go index 54750d507..96f8aebc5 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -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 +} diff --git a/router/api-router.go b/router/api-router.go index 951a0ecb1..432fab559 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) diff --git a/web/src/features/redemption-codes/api.ts b/web/src/features/redemption-codes/api.ts index 947b66f84..c14cbce94 100644 --- a/web/src/features/redemption-codes/api.ts +++ b/web/src/features/redemption-codes/api.ts @@ -98,3 +98,10 @@ export async function deleteInvalidRedemptions(): Promise> { const res = await api.delete('/api/redemption/invalid') return res.data } + +export async function batchDeleteRedemptions( + ids: number[] +): Promise> { + const res = await api.post('/api/redemption/batch', { ids }) + return res.data +} diff --git a/web/src/features/redemption-codes/components/__tests__/bulk-delete.test.tsx b/web/src/features/redemption-codes/components/__tests__/bulk-delete.test.tsx new file mode 100644 index 000000000..51bdfb055 --- /dev/null +++ b/web/src/features/redemption-codes/components/__tests__/bulk-delete.test.tsx @@ -0,0 +1,218 @@ +/* +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 . + +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) => ( + + ))} + + + ) +} + +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( + + + + + + + ) + 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((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() + } +) diff --git a/web/src/features/redemption-codes/components/__tests__/redemptions-export.test.tsx b/web/src/features/redemption-codes/components/__tests__/redemptions-export.test.tsx new file mode 100644 index 000000000..c286abb2d --- /dev/null +++ b/web/src/features/redemption-codes/components/__tests__/redemptions-export.test.tsx @@ -0,0 +1,311 @@ +/* +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 . + +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 { + 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 ( + + + + ) +} + +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( + + ) + 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( + ', 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\\] \\ | 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() + 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() + 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( + + ) + 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( + + ) + 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() +}) diff --git a/web/src/features/redemption-codes/components/data-table-bulk-actions.tsx b/web/src/features/redemption-codes/components/data-table-bulk-actions.tsx index ef65b10a2..2068acc93 100644 --- a/web/src/features/redemption-codes/components/data-table-bulk-actions.tsx +++ b/web/src/features/redemption-codes/components/data-table-bulk-actions.tsx @@ -16,44 +16,127 @@ along with this program. If not, see . 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 = { - table: Table +type DataTableBulkActionsProps = { + table: Table } -export function DataTableBulkActions({ - table, -}: DataTableBulkActionsProps) { +export function DataTableBulkActions(props: DataTableBulkActionsProps) { const { t } = useTranslation() - const selectedRows = table.getSelectedRowModel().rows + const { triggerRefresh } = useRedemptions() + const [deleteTargets, setDeleteTargets] = useState(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 ( - - + + + + + setDeleteTargets(selectedRows.map((row) => row.original)) + } + /> + } + > + + + {t('Delete selected redemption codes')} + + + + { + 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) + } + }} /> - + ) } diff --git a/web/src/features/redemption-codes/components/redemptions-export-dialog.tsx b/web/src/features/redemption-codes/components/redemptions-export-dialog.tsx new file mode 100644 index 000000000..06de1335a --- /dev/null +++ b/web/src/features/redemption-codes/components/redemptions-export-dialog.tsx @@ -0,0 +1,168 @@ +/* +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 . + +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('&', '&') + .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 ( + { + 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={} + > +
+ + +
+ {saveToFile && ( +
+ { + 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) => ( +
+ + +
+ ))} +
+
+
+ + +
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index b8f455e63..dbb164bf4 100644 --- a/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -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( + 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 ( - { - onOpenChange(v) - if (!v) { - form.reset() - } - }} - > - - - - {isUpdate - ? t('Update Redemption Code') - : t('Create Redemption Code')} - - - {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're done.')} - - -
- -
+ { + onOpenChange(v) + if (!v) { + form.reset() + } + }} + > + + + + {isUpdate + ? t('Update Redemption Code') + : t('Create Redemption Code')} + + + {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're done.')} + + + + - - ( - - {t('Name')} - - - - - {t('Name for this redemption code (1-20 characters)')} - - - - )} - /> - - ( - - {quotaLabel} - - - field.onChange( - Number.parseFloat(e.target.value) || 0 - ) - } - /> - - - {tokensOnly - ? t('Enter the quota amount in tokens') - : t('Enter the quota amount in {{currency}}', { - currency: currencyLabel, - })} - - - - )} - /> - - ( - - {t('Expiration Time')} -
- - - -
- - - - -
-
- - {t('Leave empty for never expires')} - - -
- )} - /> - - {!isUpdate && ( +
+ ( - {t('Quantity')} + {t('Name')} - - field.onChange( - Number.parseInt(e.target.value, 10) || 1 - ) - } - /> + - {t( - 'Create multiple redemption codes at once (1-100)' - )} + {t('Name for this redemption code (1-20 characters)')} )} /> - )} - -
- - - - }> - {t('Close')} - - - -
-
+ + ( + + {quotaLabel} + + + field.onChange( + Number.parseFloat(e.target.value) || 0 + ) + } + /> + + + {tokensOnly + ? t('Enter the quota amount in tokens') + : t('Enter the quota amount in {{currency}}', { + currency: currencyLabel, + })} + + + + )} + /> + + ( + + {t('Expiration Time')} +
+ + + +
+ + + + +
+
+ + {t('Leave empty for never expires')} + + +
+ )} + /> + + {!isUpdate && ( + ( + + {t('Quantity')} + + + field.onChange( + Number.parseInt(e.target.value, 10) || 1 + ) + } + /> + + + {t( + 'Create multiple redemption codes at once (1-100)' + )} + + + + )} + /> + )} + +
+ + + + }> + {t('Close')} + + + +
+
+ {createdCodes && ( + setCreatedCodes(null)} + /> + )} + ) } diff --git a/web/src/features/redemption-codes/components/redemptions-table.tsx b/web/src/features/redemption-codes/components/redemptions-table.tsx index 850433eef..ef4a90827 100644 --- a/web/src/features/redemption-codes/components/redemptions-table.tsx +++ b/web/src/features/redemption-codes/components/redemptions-table.tsx @@ -133,6 +133,7 @@ export function RedemptionsTable() { data: redemptions, columns, enableRowSelection: true, + getRowId: (row) => String(row.id), columnFilters, globalFilter, pagination, diff --git a/web/src/features/usage-logs/audit/__tests__/details.test.tsx b/web/src/features/usage-logs/audit/__tests__/details.test.tsx index 74e89c5e4..a003e6cfe 100644 --- a/web/src/features/usage-logs/audit/__tests__/details.test.tsx +++ b/web/src/features/usage-logs/audit/__tests__/details.test.tsx @@ -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] }, + ]) + ) +}) diff --git a/web/src/features/usage-logs/audit/lib/audit-details.ts b/web/src/features/usage-logs/audit/lib/audit-details.ts index 6fac3c548..e1b3452a5 100644 --- a/web/src/features/usage-logs/audit/lib/audit-details.ts +++ b/web/src/features/usage-logs/audit/lib/audit-details.ts @@ -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 : {} diff --git a/web/src/features/usage-logs/lib/format.ts b/web/src/features/usage-logs/lib/format.ts index ac94a7bac..ef942df70 100644 --- a/web/src/features/usage-logs/lib/format.ts +++ b/web/src/features/usage-logs/lib/format.ts @@ -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( diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 99238ff3b..9320e439a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index d49d51228..705f7d7e9 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -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", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 709488c11..2cb851588 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -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": "サフィックス", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 0d6e32298..52d3c07af 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Суффикс", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 154372524..66a40dcf8 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -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ố", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index a870a0dd6..d9ffb2bf5 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -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": "後綴", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 92a6022c1..502f3bf02 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "后缀",