diff --git a/frontend/src/components/common/DateRangePicker.vue b/frontend/src/components/common/DateRangePicker.vue index b00334a67a1..6a5f68aae4c 100644 --- a/frontend/src/components/common/DateRangePicker.vue +++ b/frontend/src/components/common/DateRangePicker.vue @@ -236,7 +236,7 @@ const displayValue = computed(() => { const formatDate = (dateStr: string): string => { const date = new Date(dateStr + 'T00:00:00') - const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US' + const dateLocale = locale.value === 'zh' ? 'zh-CN' : locale.value === 'es' ? 'es-ES' : 'en-US' return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' }) } diff --git a/frontend/src/i18n/__tests__/localesMessageCompile.spec.ts b/frontend/src/i18n/__tests__/localesMessageCompile.spec.ts index c507a447b94..c4aa94dc5f5 100644 --- a/frontend/src/i18n/__tests__/localesMessageCompile.spec.ts +++ b/frontend/src/i18n/__tests__/localesMessageCompile.spec.ts @@ -3,6 +3,7 @@ import { baseCompile } from '@intlify/message-compiler' import en from '../locales/en' import zh from '../locales/zh' +import es from '../locales/es' // vue-i18n 在运行时才编译消息:文案里未转义的花括号(如内嵌 JSON 示例 // "{\"user-agent\": ...}")会在渲染时抛 "Invalid token in placeholder", @@ -32,7 +33,8 @@ function collectCompileErrors(node: unknown, path: string, out: string[]): void describe('locale messages compile', () => { it.each([ ['zh', zh], - ['en', en] + ['en', en], + ['es', es] ] as const)('%s messages all compile without placeholder errors', (locale, messages) => { const errors: string[] = [] collectCompileErrors(messages, locale, errors) diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 79bf77e8bad..bf4c2a0c690 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,6 +1,6 @@ import { createI18n } from 'vue-i18n' -type LocaleCode = 'en' | 'zh' +type LocaleCode = 'en' | 'zh' | 'es' type LocaleMessages = Record @@ -9,11 +9,12 @@ const DEFAULT_LOCALE: LocaleCode = 'en' const localeLoaders: Record Promise<{ default: LocaleMessages }>> = { en: () => import('./locales/en'), - zh: () => import('./locales/zh') + zh: () => import('./locales/zh'), + es: () => import('./locales/es') } function isLocaleCode(value: string): value is LocaleCode { - return value === 'en' || value === 'zh' + return value === 'en' || value === 'zh' || value === 'es' } function getDefaultLocale(): LocaleCode { @@ -26,6 +27,9 @@ function getDefaultLocale(): LocaleCode { if (browserLang.startsWith('zh')) { return 'zh' } + if (browserLang.startsWith('es')) { + return 'es' + } return DEFAULT_LOCALE } @@ -93,7 +97,8 @@ export function getLocale(): LocaleCode { export const availableLocales = [ { code: 'en', name: 'English', flag: '🇺🇸' }, - { code: 'zh', name: '中文', flag: '🇨🇳' } + { code: 'zh', name: '中文', flag: '🇨🇳' }, + { code: 'es', name: 'Español', flag: '🇪🇸' } ] as const export default i18n diff --git a/frontend/src/i18n/locales/es/admin/accounts.ts b/frontend/src/i18n/locales/es/admin/accounts.ts new file mode 100644 index 00000000000..1d5187ca9e7 --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/accounts.ts @@ -0,0 +1,1506 @@ +export default { + accounts: { + title: 'Gestión de cuentas', + description: 'Gestiona las cuentas y credenciales de las plataformas de IA', + createAccount: 'Crear una cuenta', + autoRefresh: 'Actualización automática', + enableAutoRefresh: 'Activar la actualización automática', + refreshInterval5s: '5 segundos', + refreshInterval10s: '10 segundos', + refreshInterval15s: '15 segundos', + refreshInterval30s: '30 segundos', + autoRefreshCountdown: 'Actualización automática: {seconds} s', + listPendingSyncHint: 'Hay cambios en la lista pendientes de sincronizar. Pulsa sincronizar para cargar las filas más recientes.', + listPendingSyncAction: 'Sincronizar ahora', + syncFromCrs: 'Sincronizar desde CRS', + dataExport: 'Exportar', + dataExportSelected: 'Exportar los seleccionados', + dataExportIncludeProxies: 'Incluir los proxies vinculados a las cuentas exportadas', + dataImport: 'Importar', + moreActions: 'Más acciones', + dataActions: 'Datos', + toolActions: 'Herramientas', + viewColumns: 'Columnas', + selectedCount: '{count} seleccionadas', + dataExportConfirmMessage: 'Los datos exportados contienen información sensible de cuentas y proxies. Guárdalos en un lugar seguro.', + dataExportConfirm: 'Confirmar la exportación', + dataExported: 'Datos exportados correctamente', + dataExportedSkippedShadows: 'Datos exportados. Se omitieron {count} cuenta(s) sombra de spark: su configuración de planificación no se incluye en la copia; vuelve a crearlas y ajustarlas tras restaurar.', + dataExportFailed: 'No se pudieron exportar los datos', + dataImportTitle: 'Importar datos', + dataImportHint: 'Sube el archivo JSON exportado para importar cuentas y proxies.', + dataImportWarning: 'La importación crea cuentas y proxies nuevos; los grupos hay que vincularlos a mano. Asegúrate de que no haya conflictos con los datos existentes.', + dataImportFile: 'Archivo de datos', + dataImportButton: 'Comenzar la importación', + dataImporting: 'Importando...', + dataImportSelectFile: 'Selecciona un archivo de datos', + dataImportParseFailed: 'No se pudo interpretar el archivo de datos', + dataImportParseFailedFile: 'No se pudo interpretar {name}', + dataImportInvalidFile: '{name} no es un archivo de exportación válido', + dataImportIgnoredFiles: 'Se ignoraron {count} archivo(s) que no son JSON', + dataImportFailed: 'Falló la importación de datos', + dataImportResult: 'Resultado de la importación', + dataImportResultSummary: 'Proxies creados {proxy_created}, reutilizados {proxy_reused}, fallidos {proxy_failed}; cuentas creadas {account_created}, fallidas {account_failed}', + dataImportErrors: 'Detalle de los errores', + dataImportSuccess: 'Importación completada: cuentas {account_created}, fallidas {account_failed}', + dataImportCompletedWithErrors: 'La importación terminó con errores: cuentas fallidas {account_failed}, proxies fallidos {proxy_failed}', + syncFromCrsTitle: 'Sincronizar cuentas desde CRS', + syncFromCrsDesc: + 'Sincroniza las cuentas de claude-relay-service (CRS) en este sistema (a CRS se le llama de servidor a servidor).', + crsVersionRequirement: '⚠️ Nota: la versión de CRS debe ser ≥ v1.1.240 para admitir esta función', + crsBaseUrl: 'URL base de CRS', + crsBaseUrlPlaceholder: 'p. ej. http://127.0.0.1:3000', + crsUsername: 'Usuario', + crsPassword: 'Contraseña', + syncProxies: 'Sincronizar también los proxies (buscar por servidor/puerto/autenticación o crearlos)', + syncNow: 'Sincronizar ahora', + syncing: 'Sincronizando...', + syncMissingFields: 'Rellena la URL base, el usuario y la contraseña', + syncResult: 'Resultado de la sincronización', + syncResultSummary: 'Creadas {created}, actualizadas {updated}, omitidas {skipped}, fallidas {failed}', + syncErrors: 'Detalle de errores y omisiones', + syncCompleted: 'Sincronización completada: creadas {created}, actualizadas {updated}, omitidas {skipped}', + syncCompletedWithErrors: + 'La sincronización terminó con errores: fallidas {failed} (creadas {created}, actualizadas {updated}, omitidas {skipped})', + syncFailed: 'Falló la sincronización', + crsPreview: 'Vista previa', + crsPreviewing: 'Generando la vista previa...', + crsPreviewFailed: 'Falló la vista previa', + crsExistingAccounts: 'Cuentas existentes (se actualizarán)', + crsNewAccounts: 'Cuentas nuevas (selecciona las que quieras sincronizar)', + crsSelectAll: 'Seleccionar todas', + crsSelectNone: 'No seleccionar ninguna', + crsNoNewAccounts: 'Todas las cuentas de CRS ya están sincronizadas.', + crsWillUpdate: 'Se actualizarán {count} cuentas existentes.', + crsSelectedCount: '{count} cuentas nuevas seleccionadas', + crsUpdateBehaviorNote: + 'En las cuentas existentes solo se sincronizan los campos que devuelve CRS; los que falten conservan su valor actual. Las credenciales se combinan por clave: las que CRS no devuelva se conservan. Los proxies se conservan si «Sincronizar los proxies» está desmarcado.', + crsBack: 'Atrás', + editAccount: 'Editar la cuenta', + deleteAccount: 'Eliminar la cuenta', + searchAccounts: 'Buscar cuentas...', + notes: 'Notas', + notesPlaceholder: 'Introduce notas', + notesHint: 'Las notas son opcionales', + allPlatforms: 'Todas las plataformas', + allTypes: 'Todos los tipos', + allStatus: 'Todos los estados', + allGroups: 'Todos los grupos', + ungroupedGroup: 'Sin grupo', + oauthType: 'OAuth', + setupToken: 'Setup Token', + apiKey: 'Clave API', + // Schedulable toggle + schedulable: 'Planificable', + schedulableHint: 'Actívalo para que esta cuenta entre en el reparto de peticiones de la API', + schedulableEnabled: 'Planificación activada', + schedulableDisabled: 'Planificación desactivada', + failedToToggleSchedulable: 'No se pudo cambiar el estado de planificación', + groupCountTotal: '{count} grupos en total', + platforms: { + anthropic: 'Anthropic', + claude: 'Claude', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity', + grok: 'Grok', + }, + types: { + oauth: 'OAuth', + chatgptOauth: 'OAuth de ChatGPT', + responsesApi: 'API Responses', + googleOauth: 'OAuth de Google', + codeAssist: 'Code Assist', + antigravityOauth: 'OAuth de Antigravity', + grokOauth: 'OAuth de Grok', + antigravityApikey: 'Conectar con URL base + clave API', + upstream: 'Proveedor', + upstreamDesc: 'Conectar con URL base + clave API' + }, + antigravityProjectIdLabel: 'ID de proyecto de GCP (opcional)', + antigravityProjectIdPlaceholder: 'tu-id-de-proyecto-gcp', + antigravityProjectIdHint: + 'Las cuentas de Antigravity de nivel estándar que no reciben un project_id automático necesitan un proyecto de GCP propio.', + accountSchedulingThresholdOverride: 'Umbral propio de pausa automática', + accountSchedulingThresholdOverrideHint: + 'Sustituye el umbral de pausa automática de la plataforma solo para esta cuenta. Desactívalo para usar el de la plataforma.', + accountSchedulingThresholdOverrideValue: 'Porcentaje del umbral de la cuenta', + accountSchedulingThresholdOverrideDisabledHint: + 'Usa valores de 1 a 100. La cuenta queda temporalmente no planificable al alcanzar ese porcentaje de consumo; 100 lo desactiva para esta cuenta.', + status: { + active: 'Activa', + inactive: 'Inactiva', + error: 'Error', + cooldown: 'Enfriamiento', + paused: 'Pausada', + limited: 'Limitada', + rateLimited: 'Límite alcanzado', + overloaded: 'Sobrecargada', + tempUnschedulable: 'Temporalmente no planificable', + quotaExceeded: 'Cuota superada', + unschedulable: 'No planificable', + rateLimitedUntil: 'Límite alcanzado y retirada de la planificación. Se reanuda sola a las {time}', + rateLimitedAutoResume: 'Se reanuda sola en {time}', + modelRateLimitedUntil: '{model} con el límite alcanzado hasta las {time}', + modelCreditOveragesUntil: '{model} usando créditos de IA hasta las {time}', + creditsExhausted: 'Créditos agotados', + creditsExhaustedUntil: 'Créditos de IA agotados, recuperación prevista a las {time}', + overloadedUntil: 'Sobrecargada hasta las {time}', + viewTempUnschedDetails: 'Ver los detalles del estado no planificable', + tempUnschedulableUntil: 'Se reanuda {time}' + }, + columns: { + name: 'Nombre', + id: 'ID de la cuenta', + platformType: 'Plataforma/Tipo', + platform: 'Plataforma', + type: 'Tipo', + capacity: 'Capacidad', + notes: 'Notas', + priority: 'Prioridad', + billingRateMultiplier: 'Tarifa de facturación', + upstreamBillingRate: 'Tarifa declarada por el proveedor', + weight: 'Peso', + schedulerScore: 'Puntuación del planificador', + status: 'Estado', + schedulable: 'Planificable', + todayStats: 'Estadísticas de hoy', + groups: 'Grupos', + usageWindows: 'Ventanas de consumo', + proxy: 'Proxy', + lastUsed: 'Último uso', + createdAt: 'Creada', + expiresAt: 'Caduca el', + actions: 'Acciones' + }, + schedulerScore: { + baseShort: 'Base', + stickyShort: 'Persistencia', + ungrouped: 'Sin grupo', + hint: 'Se muestra como «grupo / puntuación base / bonificación por persistencia». La puntuación base se calcula dentro del conjunto de candidatas filtrado e incluye prioridad, carga, profundidad de la cola, tasa de error, latencia del primer token, ventana de reinicio, margen de cuota, tarifa de facturación y factores relacionados. La bonificación por persistencia solo se aplica cuando está activada la ponderación por previous_response_id o session_hash. Se prefieren las puntuaciones más altas.' + }, + usageWindowsHint: '«5h / 7d» son las ventanas de consumo oficiales de la cuenta en el proveedor (p. ej. OpenAI ChatGPT o Claude). Las impone el proveedor sobre la propia cuenta: no las configura sub2api y no tienen relación con los modelos que asignes. El consumo se reinicia solo cuando la ventana se renueva, y el límite no se puede levantar desde sub2api.', + ollamaCloud: { + title: 'Consumo de Ollama Cloud', + sessionSecurityHint: 'La sesión del navegador se guarda cifrada y solo se envía a la URL oficial fija de ajustes.', + configured: 'Configurada', + notConfigured: 'Sin configurar', + notRefreshed: 'Sin actualizar', + encryptionKeyRequired: 'Define una TOTP_ENCRYPTION_KEY permanente antes de guardar una sesión de navegador.', + sessionLabel: 'Cookie del navegador de Ollama', + sessionPlaceholder: 'wos-session=...; __Secure-authjs.session-token.0=...', + writeOnlyHint: 'Solo de escritura. El valor guardado no se puede ver y un valor vacío nunca lo sustituye.', + deleteSession: 'Eliminar la sesión', + deleteConfirm: '¿Eliminar la sesión de navegador de Ollama guardada y su instantánea de consumo?', + refreshNow: 'Actualizar el consumo', + autoRefresh: 'Actualización automática del consumo', + autoRefreshHint: 'Solo funciona cuando están activados a la vez el interruptor de la cuenta y el global.', + plan: 'Plan', + fiveHour: '5 horas', + fiveHourShort: '5 h', + sevenDay: '7 días', + sevenDayShort: '7 d', + balance: 'Saldo', + models: 'Modelos', + status: 'Estado', + updatedAt: 'Actualizado', + ok: 'Al día', + unauthorized: 'Sesión caducada', + failed: 'Falló la actualización', + windowWithReset: '{percent} usado, se reinicia {reset}', + loadFailed: 'No se pudieron cargar los ajustes de consumo de Ollama Cloud', + sessionSaved: 'Sesión de navegador de Ollama guardada', + sessionSaveFailed: 'No se pudo guardar la sesión de navegador de Ollama', + sessionDeleted: 'Sesión de navegador de Ollama eliminada', + sessionDeleteFailed: 'No se pudo eliminar la sesión de navegador de Ollama', + autoRefreshFailed: 'No se pudo actualizar la actualización automática del consumo', + refreshSuccess: 'Consumo de Ollama Cloud actualizado', + refreshFailed: 'No se pudo actualizar el consumo de Ollama Cloud', + errors: { + request_failed: 'Falló la petición', + empty_response: 'Respuesta vacía', + response_host_mismatch: 'Host de respuesta inesperado', + redirect_blocked: 'Los ajustes oficiales redirigieron la petición', + unauthorized: 'La sesión del navegador ha caducado', + http_error: 'Los ajustes oficiales devolvieron un error', + response_read_failed: 'No se pudo leer la respuesta', + response_too_large: 'La página de ajustes superó el límite de respuesta', + invalid_html: 'No se reconoció el formato de la página de ajustes', + OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: 'La actualización está limitada. Inténtalo de nuevo en {retry_after_seconds} segundos.' + } + }, + upstreamBilling: { + trustWarning: 'Esta tarifa la declara el sitio proveedor para la clave API actual. Sub2API no puede verificar que coincida con los cargos reales. El sitio proveedor o un intermediario podrían devolver datos falsos, antiguos o modificados. Compruébalo contra las facturas, los cambios de saldo y el consumo real.', + autoProbe: 'Detectar automáticamente la tarifa declarada por el proveedor', + autoProbeHint: 'Actualiza la tarifa declarada por el proveedor en el intervalo global. Este interruptor por sí solo no cambia la tarifa de la cuenta.', + syncRate: 'Sincronizar la tarifa declarada por el proveedor', + syncRateHint: 'Actualiza la tarifa de la cuenta tras cada detección correcta, usando la tarifa base sin horas punta. Las detecciones fallidas o las declaraciones fuera del rango permitido no la cambian. Activar esto también enciende «Detectar automáticamente la tarifa declarada».', + syncRateManagedHint: 'La tarifa actual se mantiene automáticamente a partir de la tarifa base declarada por el proveedor (sin horas punta).', + syncedRateTooltip: 'Esta tarifa de cuenta se sincroniza desde la tarifa base declarada por el proveedor (sin horas punta)', + manualProbe: 'Detectar ahora la tarifa del proveedor', + stale: 'Obsoleta', + unsupported: 'No compatible', + failed: 'Fallida', + notProbed: 'Sin detectar', + groupRate: 'Por defecto del grupo: {value}x', + userRate: 'Tarifa del usuario: {value}x', + peakRate: 'Horas punta: {start}-{end}, {value}x ({timezone})', + noPeakRate: 'Tarifa de horas punta: desactivada', + effectiveRate: 'Tarifa actual: {value}x', + updatedAt: 'Actualizada: {value}', + nextProbeAt: 'Próxima detección: {value}', + lastDetectedRate: 'Última tarifa detectada: {value}x', + lastDetectedAt: 'Última detección: {value}', + elapsedSince: 'Hace: {value}', + justNow: 'menos de 1 minuto', + minutesAgo: '{count} minutos', + hoursAgo: '{count} horas', + daysAgo: '{count} días', + accountProbeState: 'Detección automática en esta cuenta:', + globalProbeState: 'Interruptor global de detección:', + enabled: 'Activada', + disabled: 'Desactivada', + probeFailed: 'No se pudo detectar la tarifa del proveedor', + noEligibleAccounts: 'Selecciona cuentas con clave API', + batchLimit: 'Un lote puede detectar como máximo 20 cuentas', + batchCompleted: 'Se detectaron {count} cuenta(s)', + batchPartial: 'Detección parcialmente completada: {success} correctas, {failed} fallidas' + }, + allPrivacyModes: 'Todos los estados de privacidad', + privacyUnset: 'Sin definir', + privacyTrainingOff: 'Compartir datos de entrenamiento desactivado', + privacyCfBlocked: 'Bloqueado por Cloudflare, el entrenamiento puede seguir activo', + privacyFailed: 'No se pudo desactivar el entrenamiento', + privacyAntigravitySet: 'Telemetría y correos de marketing desactivados', + privacyAntigravityFailed: 'Falló el ajuste de privacidad', + setPrivacy: 'Configurar la privacidad', + subscriptionAbnormal: 'Anómala', + subscriptionExpires: 'Caduca', + // Capacity status tooltips + capacity: { + windowCost: { + blocked: 'Se superó el coste de la ventana de 5 h, planificación de la cuenta pausada', + stickyOnly: 'El coste de la ventana de 5 h está en el umbral, solo se permiten sesiones persistentes', + normal: 'Coste de la ventana de 5 h normal' + }, + sessions: { + full: 'Sesiones activas al máximo, las nuevas tendrán que esperar (tiempo de inactividad: {idle} min)', + normal: 'Sesiones activas normales (tiempo de inactividad: {idle} min)' + }, + rpm: { + full: 'Se alcanzó el límite de RPM', + warning: 'El RPM se acerca al límite', + normal: 'RPM normal', + tieredNormal: 'Límite de RPM (por tramos) - Normal', + tieredWarning: 'Límite de RPM (por tramos) - Acercándose al límite', + tieredStickyOnly: 'Límite de RPM (por tramos) - Solo persistentes | Margen: {buffer}', + tieredBlocked: 'Límite de RPM (por tramos) - Bloqueado | Margen: {buffer}', + stickyExemptNormal: 'Límite de RPM (persistentes exentas) - Normal', + stickyExemptWarning: 'Límite de RPM (persistentes exentas) - Acercándose al límite', + stickyExemptOver: 'Límite de RPM (persistentes exentas) - Superado, solo persistentes' + }, + quota: { + exceeded: 'Cuota superada, cuenta pausada', + normal: 'Cuota normal' + }, + }, + tempUnschedulable: { + title: 'Temporalmente no planificable', + statusTitle: 'Estado temporalmente no planificable', + hint: 'Desactiva cuentas temporalmente cuando coincidan a la vez el código de error y la palabra clave.', + notice: 'Las reglas se evalúan en orden y necesitan que coincidan tanto el código de error como la palabra clave.', + addRule: 'Añadir una regla', + ruleOrder: 'Orden de las reglas', + multipleErrorTrigger: '{count} errores coincidentes en {minutes} minutos alcanzaron el umbral ({threshold}).', + multipleErrorTriggerNoWindow: '{count} errores coincidentes alcanzaron el umbral ({threshold}).', + multipleErrorCountInWindow: 'Se produjeron {count} errores coincidentes en {minutes} minutos.', + multipleErrorCount: '{count} errores coincidentes contribuyeron a este bloqueo.', + ruleIndex: 'Regla n.º {index}', + errorCode: 'Código de error', + errorCodePlaceholder: 'p. ej. 429', + durationMinutes: 'Duración (minutos)', + durationPlaceholder: 'p. ej. 30', + keywords: 'Palabras clave', + keywordsPlaceholder: 'p. ej. overloaded, too many requests', + keywordsHint: 'Separa las palabras con comas; basta con que coincida una.', + description: 'Descripción', + descriptionPlaceholder: 'Nota opcional para esta regla', + rulesInvalid: 'Añade al menos una regla con código de error, palabras clave y duración.', + viewDetails: 'Ver los detalles del estado no planificable', + accountName: 'Cuenta', + triggeredAt: 'Se activó el', + until: 'Hasta', + remaining: 'Restante', + matchedKeyword: 'Palabra clave coincidente', + errorMessage: 'Detalles del error', + reset: 'Recuperar el estado', + resetSuccess: 'Estado de la cuenta recuperado correctamente', + resetFailed: 'No se pudo recuperar el estado de la cuenta', + failedToLoad: 'No se pudo cargar el estado no planificable', + notActive: 'Esta cuenta no está temporalmente no planificable.', + expired: 'Caducado', + remainingMinutes: 'Unos {minutes} minutos', + remainingHours: 'Unas {hours} horas', + remainingHoursMinutes: 'Unas {hours} horas y {minutes} minutos', + presets: { + overloadLabel: '529 Sobrecargado', + overloadDesc: 'Sobrecargado: pausa de 60 minutos', + rateLimitLabel: '429 Límite alcanzado', + rateLimitDesc: 'Límite alcanzado: pausa de 10 minutos', + unavailableLabel: '503 No disponible', + unavailableDesc: 'No disponible: pausa de 30 minutos' + } + }, + clearRateLimit: 'Quitar el límite', + resetQuota: 'Restablecer la cuota', + quotaLimit: 'Límite de cuota', + quotaLimitPlaceholder: '0 significa ilimitado', + quotaLimitHint: 'Define límites de gasto diario, semanal y total (USD). Las cuentas de Anthropic con clave API también pueden configurar la afinidad de cliente. Cambiar los límites no reinicia el consumo.', + quotaLimitToggle: 'Activar el límite de cuota', + quotaLimitToggleHint: 'Si se activa, la cuenta se pausará cuando el consumo alcance el límite', + quotaDailyLimit: 'Límite diario', + quotaDailyLimitHint: 'Se reinicia solo cada 24 horas desde el primer uso.', + quotaWeeklyLimit: 'Límite semanal', + quotaWeeklyLimitHint: 'Se reinicia solo cada 7 días desde el primer uso.', + quotaTotalLimit: 'Límite total', + quotaTotalLimitHint: 'Límite de gasto acumulado. No se reinicia solo: usa «Restablecer la cuota» para ponerlo a cero.', + quotaResetMode: 'Modo de reinicio', + quotaResetModeRolling: 'Ventana móvil', + quotaResetModeFixed: 'Hora fija', + quotaResetHour: 'Hora de reinicio', + quotaWeeklyResetDay: 'Día de reinicio', + quotaResetTimezone: 'Zona horaria del reinicio', + quotaDailyLimitHintFixed: 'Se reinicia cada día a las {hour}:00 ({timezone}).', + quotaWeeklyLimitHintFixed: 'Se reinicia cada {day} a las {hour}:00 ({timezone}).', + dayOfWeek: { + monday: 'Lunes', + tuesday: 'Martes', + wednesday: 'Miércoles', + thursday: 'Jueves', + friday: 'Viernes', + saturday: 'Sábado', + sunday: 'Domingo', + }, + quotaLimitAmount: 'Límite total', + quotaLimitAmountHint: 'Límite de gasto acumulado. No se reinicia solo.', + quotaNotify: { + alert: 'Alerta', + enabled: 'Activar la alerta', + threshold: 'Importe de la alerta', + thresholdPlaceholder: 'Introduce el porcentaje', + }, + testConnection: 'Probar la conexión', + reAuthorize: 'Volver a autorizar', + refreshToken: 'Renovar el token', + noAccountsYet: 'Todavía no hay cuentas', + createFirstAccount: 'Crea tu primera cuenta para empezar a usar los servicios de IA.', + tokenRefreshed: 'Token renovado correctamente', + accountDeleted: 'Cuenta eliminada correctamente', + rateLimitCleared: 'Límite quitado correctamente', + bulkSchedulableEnabled: 'Se activó la planificación en {count} cuenta(s)', + bulkSchedulableDisabled: 'Se desactivó la planificación en {count} cuenta(s)', + bulkSchedulablePartial: 'Planificación actualizada parcialmente: {success} correctas, {failed} fallidas', + bulkSchedulableResultUnknown: 'El resultado de la planificación en bloque está incompleto. Inténtalo de nuevo o actualiza la página.', + bulkActions: { + selected: '{count} cuenta(s) seleccionada(s)', + selectedAll: 'Las {count} cuenta(s) seleccionadas', + selectCurrentPage: 'Seleccionar esta página', + selectAllResults: 'Seleccionar todos los resultados ({count})', + selectingAll: 'Seleccionando todos los resultados...', + selectAllFailed: 'No se pudieron cargar todas las cuentas. Se conservó la selección anterior.', + clear: 'Limpiar la selección', + edit: 'Editar en bloque', + delete: 'Eliminar en bloque', + confirmDelete: '¿Eliminar las {count} cuenta(s) seleccionadas? Esta acción no se puede deshacer.', + deleteSuccess: 'Se eliminaron {count} cuenta(s)', + enableScheduling: 'Activar la planificación', + disableScheduling: 'Desactivar la planificación', + resetStatus: 'Restablecer el estado', + refreshToken: 'Renovar el token', + probeUpstreamBilling: 'Detectar la tarifa del proveedor', + resetStatusSuccess: 'Se restableció el estado de {count} cuenta(s)', + refreshTokenSuccess: 'Se renovó el token de {count} cuenta(s)', + partialSuccess: 'Completado parcialmente: {success} correctas, {failed} fallidas' + }, + bulkEdit: { + title: 'Editar cuentas en bloque', + selectionInfo: + '{count} cuenta(s) seleccionada(s). Solo se actualizarán los campos marcados o rellenados; el resto se queda igual.', + baseUrlPlaceholder: 'https://api.anthropic.com o https://api.openai.com', + baseUrlNotice: 'Se aplica a las cuentas con clave API y al endpoint de reenvío de las cuentas OAuth de Grok; déjalo vacío para conservar el valor actual', + submit: 'Actualizar las cuentas', + updating: 'Actualizando...', + success: 'Se actualizaron {count} cuenta(s)', + partialSuccess: 'Actualizadas parcialmente: {success} correctas, {failed} fallidas', + failed: 'Falló la actualización en bloque', + noSelection: 'Selecciona las cuentas que quieres editar', + noFieldsSelected: 'Selecciona al menos un campo para actualizar', + rateSyncWarning: 'Las cuentas con la sincronización de tarifa del proveedor activada no se pueden cambiar en bloque. Desactiva la sincronización en el editor de la cuenta primero.', + rateSyncConflict: 'No se pueden cambiar las tarifas: {count} cuenta(s) de destino tienen activada la sincronización de tarifa del proveedor.', + mixedPlatformWarning: 'Las cuentas seleccionadas abarcan varias plataformas ({platforms}). Las correspondencias de modelo que se muestran están combinadas: asegúrate de que sean adecuadas para cada plataforma.' + }, + bulkDeleteTitle: 'Eliminar cuentas en bloque', + bulkDeleteConfirm: '¿Eliminar las {count} cuenta(s) seleccionadas? Esta acción no se puede deshacer.', + bulkDeleteSuccess: 'Se eliminaron {count} cuenta(s)', + bulkDeletePartial: 'Eliminadas parcialmente: {success} correctas, {failed} fallidas', + bulkDeleteFailed: 'Falló la eliminación en bloque', + recoverState: 'Recuperar el estado', + recoverStateHint: 'Sirve para recuperar el estado de error, límite alcanzado y no planificable temporal.', + recoverStateSuccess: 'Estado de la cuenta recuperado correctamente', + recoverStateFailed: 'No se pudo recuperar el estado de la cuenta', + fallbackActive: 'Alternativo', + fallbackActiveTip: 'El proxy original {origin} ha caducado', + revertProxy: 'Volver al proxy original', + revertProxySuccess: 'Se volvió al proxy original correctamente', + revertProxyFailed: 'No se pudo volver al proxy original', + createSparkShadow: 'Crear una cuenta sombra de spark', + createSparkShadowConfirm: '¿Crear una cuenta sombra de spark vinculada a «{name}»? Comparte las credenciales de la cuenta padre y solo sirve modelos spark.', + createSparkShadowSuccess: 'Cuenta sombra de spark creada', + createSparkShadowFailed: 'No se pudo crear la cuenta sombra de spark', + duplicateAccount: 'Duplicar la cuenta', + duplicateSuccess: 'Cuenta duplicada como «{name}» y pausada. Revisa sus credenciales antes de activarla.', + duplicateFailed: 'No se pudo duplicar la cuenta', + resetStatus: 'Restablecer el estado', + statusReset: 'Estado de la cuenta restablecido correctamente', + failedToResetStatus: 'No se pudo restablecer el estado de la cuenta', + failedToLoad: 'No se pudieron cargar las cuentas', + failedToRefresh: 'No se pudo renovar el token', + failedToDelete: 'No se pudo eliminar la cuenta', + failedToClearRateLimit: 'No se pudo quitar el límite', + deleteConfirm: '¿Seguro que quieres eliminar «{name}»? Esta acción no se puede deshacer.', + // Create/Edit Account Modal + platform: 'Plataforma', + accountName: 'Nombre de la cuenta', + enterAccountName: 'Introduce el nombre de la cuenta', + accountType: 'Tipo de cuenta', + claudeCode: 'Claude Code', + claudeConsole: 'Consola de Claude', + bedrockLabel: 'AWS Bedrock', + bedrockDesc: 'SigV4 / clave API', + vertexLabel: 'Vertex', + vertexDesc: 'Cuenta de servicio', + vertexAnthropicHint: 'Usa un JSON de cuenta de servicio de Google Cloud para llamar a Claude de Anthropic a través de Vertex AI. Se recomienda configurar la correspondencia de modelos para convertir los nombres de modelo de Claude del cliente en IDs de modelo de Vertex.', + vertexGeminiHint: 'Usa un JSON de cuenta de servicio de Google Cloud para acceder a Gemini en Vertex AI. Se recomienda poner las cuentas de Vertex en un grupo aparte para no mezclarlas con AI Studio o el OAuth de Gemini en los mismos modelos.', + vertexSaJsonLabel: 'JSON de la cuenta de servicio', + vertexSaJsonLoaded: 'JSON de la cuenta de servicio cargado', + vertexSaJsonDrop: 'Suelta aquí el JSON de la cuenta de servicio', + vertexSaJsonKeyHidden: 'El contenido de la clave no se muestra en el formulario.', + vertexSaJsonDropHint: 'Arrastra aquí un archivo .json o pulsa el botón para seleccionarlo.', + vertexSaJsonSelectBtn: 'Seleccionar el JSON', + vertexSaJsonUploadHint: 'Al subir o soltar un archivo JSON, el project_id se extrae automáticamente. El contenido de la clave solo se usa para crear la cuenta.', + vertexSaJsonEditHint: 'El JSON de la cuenta de servicio no se muestra en la página de edición; para cambiarlo, elimina la cuenta y créala de nuevo.', + vertexProjectIdPlaceholder: 'Se extrae automáticamente del JSON', + vertexLocationHint: 'Las ubicaciones disponibles varían según el modelo de Vertex. Selecciona la ubicación del endpoint por defecto de esta cuenta.', + vertexLocationRequired: 'Introduce una ubicación de Vertex', + vertexSaJsonMissingFields: 'Al JSON de la cuenta de servicio le falta project_id, client_email o private_key', + vertexSaJsonMissingProjectId: 'Al JSON de la cuenta de servicio le falta project_id', + vertexSaJsonMissingClientEmail: 'Al JSON de la cuenta de servicio le falta client_email', + vertexSaJsonInvalid: 'El formato del JSON de la cuenta de servicio no es válido', + vertexSaJsonRequired: 'Sube un JSON de cuenta de servicio', + oauthSetupToken: 'OAuth / Setup Token', + addMethod: 'Método de alta', + setupTokenLongLived: 'Setup Token (larga duración)', + baseUrl: 'URL base', + baseUrlHint: 'Déjalo por defecto para la API oficial de Anthropic', + apiKeyRequired: 'Clave API *', + apiKeyPlaceholder: 'sk-ant-api03-...', + apiKeyHint: 'Tu clave API de la consola de Claude', + // OpenAI specific hints + openai: { + baseUrlHint: 'Déjalo por defecto para la API oficial de OpenAI', + apiKeyHint: 'Tu clave API de OpenAI', + oauthPassthrough: 'Paso directo automático (solo autenticación)', + oauthPassthroughDesc: + 'Si se activa, esta cuenta de OpenAI usa el paso directo automático: la pasarela reenvía la petición y la respuesta tal cual y solo cambia la autenticación, manteniendo la facturación, la concurrencia, la auditoría y el filtrado de seguridad necesario.', + flattenNamespaces: 'Aplanar las herramientas con espacio de nombres de Codex (compatibilidad)', + flattenNamespacesDesc: + 'Desactivado por defecto: las declaraciones de herramientas con espacio de nombres de Codex se reenvían tal cual en /responses, que es lo que espera el backend de Codex de ChatGPT. Actívalo solo si esta cuenta OAuth va a un relé que rechaza esas herramientas: al aplanarlas se renombran a namespace__tool, lo que rompe los modelos que llaman a las herramientas de colaboración como functions... Las peticiones de compactación siempre se aplanan, con este interruptor o sin él.', + longContextBilling: 'Precios de contexto largo de la API', + longContextBillingDesc: + 'Desactivado por defecto. Actívalo solo si el proveedor de esta cuenta cobra las tarifas de contexto largo de la API de OpenAI por encima del umbral del modelo.', + responsesWebsocketsV2: 'WebSocket de Responses v2', + responsesWebsocketsV2Desc: + 'Desactivado por defecto. Actívalo para permitir la capacidad responses_websockets_v2 (sigue dependiendo de los interruptores global y de tipo de cuenta).', + wsMode: 'Modo WS', + wsModeDesc: + 'Solo se aplica al tipo de cuenta de OpenAI actual; los modos WS de la cuenta, incluido http_bridge, solo funcionan cuando el global gateway.openai_ws.mode_router_v2_enabled=true.', + wsModeOff: 'Desactivado (off)', + wsModeCtxPool: 'Grupo de contexto (ctx_pool)', + wsModePassthrough: 'Paso directo (passthrough)', + wsModeHttpBridge: 'Puente HTTP (http_bridge)', + wsModeShared: 'Compartido (shared)', + wsModeDedicated: 'Dedicado (dedicated)', + wsModeConcurrencyHint: + 'Cuando el modo WS está activado, la concurrencia de la cuenta pasa a ser el límite del grupo de conexiones WS de esa cuenta.', + wsModePassthroughHint: 'El modo de paso directo no usa el grupo de conexiones WS.', + oauthResponsesWebsocketsV2: 'Modo WebSocket para OAuth', + oauthResponsesWebsocketsV2Desc: + 'Solo se aplica al OAuth de OpenAI. Esta cuenta solo puede usar el modo WebSocket de OpenAI si está activado.', + apiKeyResponsesWebsocketsV2: 'Modo WebSocket para clave API', + apiKeyResponsesWebsocketsV2Desc: + 'Solo se aplica a las claves API de OpenAI. Esta cuenta solo puede usar el modo WebSocket de OpenAI si está activado.', + responsesWebsocketsV2PassthroughHint: + 'El paso directo automático está activado: solo afecta al paso directo HTTP y no desactiva el modo WS.', + responsesMode: 'Compatibilidad con la API Responses', + responsesModeDesc: + 'Solo se aplica a la ruta de reenvío de texto con clave API de OpenAI. El modo automático sigue los resultados de la detección; los modos forzados la anulan.', + responsesModeAuto: 'Automático', + responsesModeForceResponses: 'Forzar Responses', + responsesModeForceChatCompletions: 'Forzar Chat Completions', + responsesModeTextDisabledHint: + 'No aplica cuando el endpoint de Responses o Chat Completions no está activado.', + endpointCapabilities: 'Capacidades del endpoint', + endpointCapabilitiesDesc: + 'Las usa el enrutado de cuentas. El endpoint de texto sigue el ajuste de compatibilidad con Responses de arriba y se muestra como Responses, Chat Completions o modo automático; Embeddings controla /v1/embeddings de forma independiente.', + capabilityResponses: 'Responses', + capabilityTextAuto: 'Responses / Chat Completions (automático)', + capabilityResponsesAuto: 'Responses (detección automática)', + capabilityChatCompletions: 'Chat Completions', + capabilityChatCompletionsAuto: 'Chat Completions (detección automática)', + capabilityEmbeddings: 'Embeddings', + responsesStatusAutoSupported: 'Detección automática: Responses', + responsesStatusAutoUnsupported: 'Detección automática: Chat Completions', + responsesStatusAutoUnknown: 'Detección automática: desconocido', + responsesStatusForcedResponses: 'Responses forzado', + responsesStatusForcedChatCompletions: 'Chat Completions forzado', + planType: 'Nivel del plan (ajuste manual)', + planTypeDesc: + 'Corrige a mano el nivel del plan de ChatGPT de esta cuenta (Plus / Pro / Free). Ojo: una renovación de token cerca de la caducidad o una respuesta 429 sobrescribirán esto con el nivel real.', + planTypeClear: 'Borrar (detección automática)', + codexCLIOnly: 'Solo clientes oficiales de Codex', + codexCLIOnlyDesc: + 'Solo se aplica al OAuth de OpenAI. Si se activa, solo se permiten las familias de clientes oficiales de Codex; si se desactiva, la pasarela se salta esta restricción y mantiene el comportamiento actual.', + codexCLIOnlyAppServer: 'Permitir clientes app-server de Codex', + codexCLIOnlyAppServerDesc: + 'Solo funciona si el interruptor de arriba está activado. Si se activa, esta cuenta también admite clientes de terceros que integran el motor de Codex por el protocolo app-server (p. ej. el plugin de codex de Claude Code); siguen pasando la comprobación global de huella del motor. Se combina con O con el interruptor global de app-server.', + codexFingerprintMode: 'Convergencia de huella de Codex', + codexFingerprintModeDesc: 'Cuando varios usuarios comparten la misma cuenta OAuth, converge los identificadores de dispositivo y sesión a valores estables por cuenta para reducir el número de dispositivos y sesiones que ve el proveedor. Desactivado = se pasan los identificadores del cliente tal cual.', + codexFingerprintOff: 'Desactivada (paso directo)', + codexFingerprintDevice: 'Solo dispositivo', + codexFingerprintSession: 'Dispositivo + sesión (recomendado)', + codexFingerprintFull: 'Convergencia total', + codexImageTool: 'Política del puente de imagen de Codex', + codexImageToolDesc: + 'Controla el puente alojado image_generation y las herramientas de imagen que declara el cliente en las peticiones de texto de Codex /responses. La inyección automática alojada solo se aplica a las peticiones que no son Responses Lite. La política de la cuenta manda sobre la del canal y la global; los endpoints de generación de imagen independientes no se ven afectados.', + codexImageToolInherit: 'Seguir la del canal', + codexImageToolInheritDesc: 'Sin excepción de cuenta; la inyección alojada en peticiones que no son Lite sigue la política del canal o la global, mientras que las herramientas alojadas del cliente y las declaraciones locales de image_gen pasan tal cual.', + codexImageToolEnabled: 'Activar el puente alojado', + codexImageToolEnabledDesc: 'Inyecta la herramienta alojada image_generation solo en las peticiones que no son Responses Lite; las herramientas de imagen del cliente siguen pasando.', + codexImageToolDisabled: 'Sin inyección alojada', + codexImageToolDisabledDesc: 'No inyecta la herramienta alojada; las herramientas alojadas del cliente y las declaraciones locales de image_gen siguen pasando.', + codexImageToolBlock: 'Quitar las herramientas de imagen del cliente', + codexImageToolBlockDesc: 'No inyecta nada por el puente y elimina las herramientas alojadas image_generation del cliente, las declaraciones locales de image_gen y el tool_choice correspondiente. El enrutado de modelos solo de imagen no se ve afectado.', + codexImageToolBadgeInherit: 'Política del canal', + codexImageToolBadgeEnabled: 'Puente alojado activo', + codexImageToolBadgeDisabled: 'Sin inyección alojada', + codexImageToolBadgeBlock: 'Herramientas de imagen del cliente eliminadas', + compactMode: 'Modo compacto', + compactModeDesc: + 'Controla cómo participa esta cuenta en el enrutado de /responses/compact. Automático sigue los resultados de la detección, Forzar activado siempre lo permite y Forzar desactivado siempre la excluye.', + compactModeAuto: 'Automático', + compactModeForceOn: 'Forzar activado', + compactModeForceOff: 'Forzar desactivado', + compactModelMapping: 'Correspondencia de modelo solo para compacto', + compactModelMappingDesc: + 'Solo se aplica a /responses/compact. Úsalo cuando el endpoint compacto del proveedor necesite un modelo compacto especial.', + compactSupported: 'Compacto admitido', + compactUnsupported: 'Compacto no admitido', + compactAuto: 'Compacto automático', + compactUnknown: 'Compacto automático', + compactLastChecked: 'Última detección de compacto', + testMode: 'Modo de prueba', + testModeDefault: 'Petición por defecto', + testModeCompact: 'Detección de compacto', + modelRestrictionDisabledByPassthrough: 'El paso directo automático está activado: la lista blanca y la correspondencia de modelos no se aplicarán.', + }, + grok: { + baseUrlHint: 'Las cuentas OAuth de Grok reenvían a la URL base oficial de la API de xAI.', + apiKeyHint: 'La compatibilidad con suscripciones de Grok usa tokens de renovación OAuth; las claves API quedan fuera del alcance de este tipo de cuenta.', + // Account connectivity test modal + testMode: 'Modo de prueba', + testModeHint: + 'Texto, imagen y vídeo usan el modelo seleccionado. La búsqueda web, TTS, STT y tiempo real llaman a endpoints independientes (no son un chat libre con herramientas).', + testModeText: 'Texto (Responses)', + testModeImage: 'Imagen (/images/generations)', + testModeVideo: 'Vídeo (/videos/generations)', + testModeSearch: 'Búsqueda web (/web_search)', + testModeTTS: 'TTS (/tts)', + testModeSTT: 'STT (/stt)', + testModeRealtime: 'Tiempo real (WS /realtime)', + textTestMode: 'Modo: texto (Responses)', + searchTestMode: 'Modo: búsqueda web (/web_search)', + ttsTestMode: 'Modo: TTS (/tts)', + sttTestMode: 'Modo: STT (/stt)', + realtimeTestMode: 'Modo: tiempo real (WS /realtime)', + searchQueryLabel: 'Consulta de búsqueda', + searchQueryPlaceholder: 'Ejemplo: xAI Grok', + searchQueryDefault: 'xAI Grok', + searchTestHint: + 'Prueba independiente de web_search (igual que el /v1/web_search de la pasarela). No es un chat libre con herramientas.', + ttsTextLabel: 'Texto para TTS', + ttsTextPlaceholder: 'Ejemplo: Hola desde la prueba de conectividad de Sub2API.', + ttsTextDefault: 'Hola desde la prueba de conectividad de la cuenta de Sub2API.', + ttsTestHint: 'Llama a /v1/tts de forma independiente con language=en; si sale bien, informa del tamaño del audio en bytes.', + sttTestHint: 'Llama a /v1/stt de forma independiente con un WAV silencioso sintético; si sale bien, el endpoint es accesible.', + realtimeTestHint: + 'Conexión WebSocket independiente a /v1/realtime (model=grok-voice-latest). Si el saludo funciona, la conectividad está bien; también puede mostrar el primer evento del servidor.', + sendingSearchRequest: 'Enviando la petición independiente de web_search...', + sendingTTSRequest: 'Enviando la petición independiente de /tts...', + sendingSTTRequest: 'Enviando la petición independiente de /stt...', + sendingRealtimeRequest: 'Conectando el WebSocket independiente de /realtime...', + selectedTestMode: 'Modo de prueba: {mode}', + imageUploadLabel: 'Imagen de origen (opcional, para ediciones)', + videoFirstFrameLabel: 'Imagen del primer fotograma o de referencia (opcional)', + imageUploadHint: + 'Se recomienda PNG o JPEG, ambos lados ≥ 8 px y menos de unos 4 MB para las ediciones. Si subes una imagen de origen, se pasa a /images/edits (de imagen a imagen). Déjalo vacío para el /images/generations de texto a imagen.', + videoFirstFrameHint: + 'Imagen opcional de primer fotograma o referencia para pasar de imagen a vídeo. Se recomienda PNG o JPEG, ambos lados ≥ 8 px.', + audioUploadLabel: 'Archivo de audio (opcional para STT)', + audioUploadHint: + 'Sube un audio real para transcribirlo. Sin archivo se usa un WAV silencioso solo para comprobar la conectividad.', + mediaTooLarge: 'El archivo es demasiado grande (máximo unos 6 MB en las pruebas de administración).', + chooseImageFile: 'Elegir una imagen', + chooseAudioFile: 'Elegir un audio', + uploadPreviewAlt: 'Vista previa de la subida', + fileReadFailed: 'No se pudo leer el archivo seleccionado', + noResponseBody: 'El servidor no devolvió cuerpo de respuesta' + }, + anthropic: { + apiKeyPassthrough: 'Paso directo automático (solo autenticación)', + apiKeyPassthroughDesc: + 'Solo se aplica a las cuentas de Anthropic con clave API. Si se activa, messages y count_tokens se reenvían en modo paso directo cambiando solo la autenticación, y se mantienen la facturación, la concurrencia, la auditoría y el filtrado de seguridad. Desactívalo para volver atrás de inmediato.', + apiKeyAuthScheme: 'Esquema de autenticación del proveedor', + apiKeyAuthSchemeDesc: 'Elige la cabecera de autenticación con clave API que se usa al reenviar a un proveedor compatible con Anthropic. Ollama Cloud usa Authorization: Bearer.', + apiKeyAuthSchemeXApiKey: 'x-api-key', + apiKeyAuthSchemeBearer: 'Authorization: Bearer', + webSearchEmulation: 'Emulación de búsqueda web', + webSearchEmulationDesc: + 'Activa la emulación de búsqueda web en esta cuenta con clave API. Cuando se detecta una petición pura de web_search, la pasarela llama a una API de búsqueda de terceros y construye la respuesta localmente. Por defecto sigue la configuración del canal.', + webSearchDefault: 'Por defecto', + webSearchEnabled: 'Activada', + webSearchDisabled: 'Desactivada', + }, + modelRestriction: 'Restricción de modelos (opcional)', + modelWhitelist: 'Lista blanca de modelos', + modelMapping: 'Correspondencia de modelos', + selectAllowedModels: 'Selecciona los modelos permitidos. Déjalo vacío para admitir todos.', + mapRequestModels: + 'Convierte los modelos solicitados en modelos reales. A la izquierda el modelo que se pide, a la derecha el que se envía a la API.', + selectedModels: '{count} modelo(s) seleccionado(s)', + supportsAllModels: '(admite todos los modelos)', + requestModel: 'Modelo solicitado', + actualModel: 'Modelo real', + addMapping: 'Añadir una correspondencia', + mappingExists: 'Ya existe una correspondencia para {model}', + wildcardOnlyAtEnd: 'El comodín * solo puede ir al final', + targetNoWildcard: 'El modelo de destino no puede llevar el comodín *', + searchModels: 'Buscar modelos...', + noMatchingModels: 'No hay modelos que coincidan', + fillRelatedModels: 'Sincronizar los últimos modelos admitidos', + syncUpstreamModels: 'Sincronizar los modelos que admite el proveedor', + syncUpstreamModelsLoading: 'Sincronizando con el proveedor...', + syncUpstreamModelsSuccess: 'Se sincronizaron {count} modelo(s) nuevo(s) del proveedor ({total} en total)', + syncUpstreamModelsNoChanges: 'Los {count} modelo(s) del proveedor ya están en la lista blanca', + syncUpstreamModelsEmpty: 'El proveedor no devolvió modelos que sincronizar', + syncUpstreamModelsFailed: 'No se pudieron sincronizar los modelos del proveedor', + syncUpstreamModelsError: 'No se pudieron sincronizar los modelos del proveedor: {message}', + clearAllModels: 'Borrar todos los modelos', + customModelName: 'Nombre de modelo personalizado', + enterCustomModelName: 'Introduce el nombre del modelo personalizado', + addModel: 'Añadir', + modelExists: 'El modelo ya existe', + modelCount: '{count} modelos', + poolMode: 'Modo grupo', + poolModeHint: 'Actívalo cuando el proveedor sea a su vez un grupo de cuentas; los errores no marcarán el estado de la cuenta local', + poolModeInfo: + 'Si se activa, los errores 429/403/401 del proveedor se reintentan automáticamente sin marcar la cuenta como limitada o con error. Es adecuado cuando el proveedor apunta a otra instancia de sub2api.', + poolModeRetryCount: 'Reintentos en la misma cuenta', + poolModeRetryCountHint: + 'Solo se aplica en el modo grupo. Usa 0 para desactivar el reintento en el sitio. Por defecto {default}, máximo {max}.', + poolModeRetryStatusCodes: 'Códigos de estado que reintentan', + poolModeRetryStatusCodesHint: + 'Códigos HTTP separados por comas (100-599) que provocan un reintento en la misma cuenta en modo grupo. Déjalo vacío para usar los valores por defecto ({default}).', + customErrorCodes: 'Códigos de error personalizados', + customErrorCodesHint: 'Detener la planificación solo con los códigos de error seleccionados', + customErrorCodesWarning: + 'Solo los códigos de error seleccionados detendrán la planificación. El resto de errores devolverán un 500.', + customErrorCodes429Warning: + 'El 429 ya tiene una gestión integrada de límite de tasa. Añadirlo a los códigos personalizados desactivará la cuenta en lugar de limitarla temporalmente. ¿Seguro?', + customErrorCodes529Warning: + 'El 529 ya tiene una gestión integrada de sobrecarga. Añadirlo a los códigos personalizados desactivará la cuenta en lugar de marcarla como sobrecargada temporalmente. ¿Seguro?', + selectedErrorCodes: 'Seleccionados', + noneSelectedUsesDefault: 'Ninguno seleccionado (se usa la política por defecto)', + enterErrorCode: 'Introduce el código de error (100-599)', + invalidErrorCode: 'Introduce un código de error HTTP válido (100-599)', + errorCodeExists: 'Este código de error ya está seleccionado', + interceptWarmupRequests: 'Interceptar las peticiones de calentamiento', + interceptWarmupRequestsDesc: + 'Si se activa, las peticiones de calentamiento como la generación de títulos devolverán respuestas simuladas sin consumir tokens del proveedor', + headerOverride: { + title: 'Sustitución de cabeceras', + hint: 'Sustituye las cabeceras del mismo nombre al reenviar (sin distinguir mayúsculas)', + info: 'Se aplica solo a las peticiones salientes de esta cuenta: las cabeceras configuradas sustituyen a las del cliente o de la pasarela con el mismo nombre antes de reenviar. Las cabeceras de autenticación (authorization, x-api-key) y las de control de conexión no se pueden sustituir.', + namePlaceholder: 'Nombre de la cabecera (p. ej. user-agent)', + valuePlaceholder: 'Valor de sustitución (déjalo vacío para omitirla)', + addRow: 'Añadir una cabecera', + importJson: 'Importar JSON', + importJsonApply: 'Interpretar y rellenar', + importJsonCancel: 'Cancelar', + importJsonHint: 'Pega un objeto JSON plano (nombre de cabecera → valor). Al interpretarlo se sustituyen las filas actuales.', + importJsonInvalid: 'JSON no válido: se esperaba un objeto plano de nombre de cabecera → valor de texto', + copyJson: 'Copiar como JSON', + emptyValueHint: 'Las filas con el valor vacío son marcadores y no sustituyen nada.', + bulkDisableHint: 'Al guardar se desactivará la sustitución de cabeceras y se borrará la configuración existente en las cuentas seleccionadas.', + bulkReplaceHint: 'Al guardar se sustituirá la configuración de cabeceras existente de todas las cuentas seleccionadas por las filas de abajo.', + bulkEmptyRows: 'Añade al menos una fila de cabecera antes de guardar, o desactiva el interruptor para borrar la configuración existente.', + invalidName: 'Nombre de cabecera no válido (solo se permiten letras, dígitos y !#$%&\'*+-.^_`|~)', + blockedName: 'Esta cabecera no se puede sustituir (las de autenticación y control de conexión las gestiona el sistema)', + duplicateName: 'Nombre de cabecera duplicado (la comparación no distingue mayúsculas)', + invalidValue: 'Valor de cabecera no válido (no se permiten caracteres de control; longitud máxima 8192)', + tooManyEntries: 'Demasiadas sustituciones de cabecera (máximo 64)' + }, + grokCustomBaseUrl: { + title: 'URL de proveedor personalizada', + hint: 'Si se activa, el tráfico de la cuenta (chat, multimedia y sondas) se reenvía a la dirección indicada. La autorización OAuth y la renovación del token no se ven afectadas y siguen usando los endpoints oficiales.', + placeholder: 'https://relay.ejemplo.com/v1', + required: 'Hace falta una dirección cuando la URL de proveedor personalizada está activada', + invalid: 'Dirección de proveedor no válida (debe ser una URL http(s):// completa)', + presets: { + cli: 'Grok Build CLI', + official: 'API oficial' + } + }, + grokClientToolCache: { + title: 'Caché de herramientas del cliente (puede cambiar la selección automática de herramientas)', + hint: 'En las cuentas OAuth de Grok Free detectadas, esto se activa por defecto para las herramientas de función de clientes como Codex y Trae. Desactívalo si el comportamiento de selección automática de herramientas no te sirve.' + }, + autoPauseOnExpired: 'Pausar automáticamente al caducar', + autoPauseOnExpiredDesc: 'Si se activa, la cuenta pausará la planificación automáticamente cuando caduque', + autoPause5hThreshold: 'Umbral de consumo de 5 h (%)', + autoPause7dThreshold: 'Umbral de consumo de 7 d (%)', + autoPauseThresholdHint: 'Déjalo vacío o en 0 para usar el umbral global por defecto (configurado en los ajustes de operaciones); pon un valor para sustituirlo. Alcanzar el umbral solo hace que se salte la cuenta al planificar, no cambia el campo planificable.', + autoPause5hDisabled: 'Desactivar la pausa automática de 5 h', + autoPause7dDisabled: 'Desactivar la pausa automática de 7 d', + autoPauseDisabledHint: 'Si se activa, esta cuenta nunca se pausa automáticamente (aunque haya un umbral global configurado).', + // Quota control (Anthropic OAuth/SetupToken only) + quotaControl: { + title: 'Control de cuota', + hint: 'Configura la ventana de coste, los límites de sesión, la afinidad de cliente y otros controles de planificación.', + windowCost: { + label: 'Límite de coste de la ventana de 5 h', + hint: 'Limita el coste que consume la cuenta dentro de la ventana de 5 horas', + limit: 'Umbral de coste', + limitPlaceholder: '50', + limitHint: 'La cuenta dejará de entrar en la planificación cuando alcance el umbral', + stickyReserve: 'Reserva para persistentes', + stickyReservePlaceholder: '10', + stickyReserveHint: 'Reserva adicional para las sesiones persistentes' + }, + sessionLimit: { + label: 'Límite de sesiones', + hint: 'Limita el número de sesiones simultáneas activas', + maxSessions: 'Sesiones máximas', + maxSessionsPlaceholder: '3', + maxSessionsHint: 'Número máximo de sesiones simultáneas activas', + idleTimeout: 'Tiempo de inactividad', + idleTimeoutPlaceholder: '5', + idleTimeoutHint: 'Las sesiones se liberan tras este tiempo de inactividad' + }, + rpmLimit: { + label: 'Límite de RPM', + hint: 'Limita las peticiones por minuto para proteger las cuentas del proveedor', + baseRpm: 'RPM base', + baseRpmPlaceholder: '15', + baseRpmHint: 'Máximo de peticiones por minuto, 0 o vacío significa sin límite', + strategy: 'Estrategia de RPM', + strategyTiered: 'Modelo por tramos', + strategyStickyExempt: 'Persistentes exentas', + strategyTieredHint: 'Verde → amarillo → solo persistentes → bloqueado, restricción progresiva', + strategyStickyExemptHint: 'Al superar el límite solo se permiten las sesiones persistentes', + strategyHint: 'Por tramos: restringe gradualmente al superarse; persistentes exentas: las sesiones existentes no se limitan', + stickyBuffer: 'Margen para persistentes', + stickyBufferPlaceholder: 'Por defecto: 20 % del RPM base', + stickyBufferHint: 'Peticiones extra permitidas a las sesiones persistentes tras superar el RPM base. Déjalo vacío para usar el valor por defecto (20 % del RPM base, mínimo 1)', + userMsgQueue: 'Control de ritmo de los mensajes del usuario', + userMsgQueueHint: 'Limita el ritmo de los mensajes del usuario para no disparar los límites de RPM del proveedor', + umqModeOff: 'Desactivado', + umqModeThrottle: 'Ralentizar', + umqModeSerialize: 'Serializar', + }, + tlsFingerprint: { + label: 'Simulación de huella TLS', + hint: 'Simula la huella TLS del cliente Node.js o Claude Code', + defaultProfile: 'Valor por defecto integrado', + randomProfile: 'Aleatoria' + }, + sessionIdMasking: { + label: 'Enmascarado del ID de sesión', + hint: 'Si se activa, fija el ID de sesión en metadata.user_id durante 15 minutos, para que el proveedor crea que las peticiones vienen de la misma sesión' + }, + cacheTTLOverride: { + label: 'Sustitución del TTL de caché', + hint: 'Fuerza que todos los tokens de creación de caché se facturen en el tramo de TTL seleccionado (5 min o 1 h)', + target: 'TTL de destino', + targetHint: 'Selecciona el tramo de TTL para la facturación' + }, + customBaseUrl: { + label: 'URL de relé personalizada', + hint: 'Reenvía las peticiones a un servicio de relé propio. La URL del proxy se pasará como parámetro de consulta.', + urlHint: 'URL del servicio de relé (p. ej. https://relay.ejemplo.com)', + }, + clientAffinity: { + label: 'Planificación por afinidad de cliente', + hint: 'Si se activa, las sesiones nuevas prefieren las cuentas que ya usó ese cliente, para reducir los cambios de cuenta' + } + }, + affinityNoClients: 'Sin clientes con afinidad', + affinityClients: '{count} clientes con afinidad:', + affinitySection: 'Afinidad de cliente', + affinitySectionHint: 'Controla cómo se reparten los clientes entre las cuentas. Configura los umbrales de las zonas para equilibrar la carga.', + affinityToggle: 'Activar la afinidad de cliente', + affinityToggleHint: 'Las sesiones nuevas prefieren las cuentas que ya usó ese cliente', + affinityBase: 'Límite base (zona verde)', + affinityBasePlaceholder: 'Vacío = sin límite', + affinityBaseHint: 'Máximo de clientes en la zona verde (planificación con prioridad total)', + affinityBaseOffHint: 'Sin límite en la zona verde. Todos los clientes reciben planificación con prioridad total.', + affinityBuffer: 'Margen (zona amarilla)', + affinityBufferPlaceholder: 'p. ej. 3', + affinityBufferHint: 'Clientes adicionales permitidos en la zona amarilla (prioridad reducida)', + affinityBufferInfinite: 'Ilimitado', + expired: 'Caducada', + proxy: 'Proxy', + noProxy: 'Sin proxy', + concurrency: 'Concurrencia', + loadFactor: 'Factor de carga', + loadFactorHint: 'Un factor de carga más alto aumenta la frecuencia con la que se planifica', + priority: 'Prioridad', + priorityHint: 'Las cuentas con menor valor se usan primero', + billingRateMultiplier: 'Multiplicador de la tarifa de facturación', + billingRateMultiplierHint: '0 = gratis, solo afecta a la facturación de la cuenta', + expiresAt: 'Caduca el', + expiresAtHint: 'Déjalo vacío para que no caduque', + higherPriorityFirst: 'Un valor más bajo significa más prioridad', + mixedScheduling: 'Usar en /v1/messages', + mixedSchedulingHint: 'Actívalo para participar en la planificación de los grupos de Anthropic y Gemini', + mixedSchedulingTooltip: + '!! AVISO !! El Claude de Antigravity y el Claude de Anthropic no se pueden usar en el mismo contexto. Si tienes cuentas de ambos, activar esta opción provocará errores 400 constantes. Si lo activas, usa los grupos para aislar las cuentas de Antigravity de las de Anthropic. ¡Asegúrate de entender esto antes de activarlo!', + aiCreditsBalance: 'Créditos de IA', + allowOverages: 'Permitir el exceso (créditos de IA)', + allowOveragesTooltip: + 'Usa los créditos de IA solo cuando la cuota gratuita se haya agotado explícitamente. Los 429 normales por concurrencia no pasarán a consumir créditos.', + creating: 'Creando...', + updating: 'Actualizando...', + accountCreated: 'Cuenta creada correctamente', + accountUpdated: 'Cuenta actualizada correctamente', + failedToCreate: 'No se pudo crear la cuenta', + failedToUpdate: 'No se pudo actualizar la cuenta', + pleaseSelectStatus: 'Selecciona un estado de cuenta válido', + mixedChannelWarningTitle: 'Aviso de mezcla de canales', + mixedChannelWarning: 'Aviso: el grupo «{groupName}» contiene cuentas de {currentPlatform} y de {otherPlatform}. Mezclar canales distintos puede provocar problemas de validación de la firma de los bloques de razonamiento, que harán que se pase al modo sin razonamiento. ¿Seguro que quieres continuar?', + pleaseEnterAccountName: 'Introduce el nombre de la cuenta', + pleaseEnterApiKey: 'Introduce la clave API', + bedrockAccessKeyId: 'AWS Access Key ID', + bedrockSecretAccessKey: 'AWS Secret Access Key', + bedrockSessionToken: 'AWS Session Token', + bedrockRegion: 'Región de AWS', + bedrockRegionHint: 'p. ej. us-east-1, us-west-2, eu-west-1', + bedrockForceGlobal: 'Forzar la inferencia global entre regiones', + bedrockForceGlobalHint: 'Si se activa, los IDs de modelo llevan el prefijo global. (p. ej. global.anthropic.claude-...), enrutando las peticiones a cualquier región compatible del mundo para tener más disponibilidad', + bedrockAccessKeyIdRequired: 'Introduce el AWS Access Key ID', + bedrockSecretAccessKeyRequired: 'Introduce el AWS Secret Access Key', + bedrockRegionRequired: 'Selecciona la región de AWS', + bedrockSessionTokenHint: 'Opcional, para credenciales temporales', + bedrockSecretKeyLeaveEmpty: 'Déjalo vacío para conservar la clave actual', + bedrockAuthMode: 'Modo de autenticación', + bedrockAuthModeSigv4: 'Firma SigV4', + bedrockAuthModeApikey: 'Clave API de Bedrock', + bedrockApiKeyLabel: 'Clave API de Bedrock', + bedrockApiKeyDesc: 'Bearer Token', + bedrockApiKeyInput: 'Clave API', + bedrockApiKeyRequired: 'Introduce la clave API de Bedrock', + bedrockApiKeyLeaveEmpty: 'Déjalo vacío para conservar la clave actual', + apiKeyIsRequired: 'La clave API es obligatoria', + leaveEmptyToKeep: 'Déjalo vacío para conservar la clave actual', + // Upstream type + upstream: { + baseUrl: 'URL base del proveedor', + baseUrlHint: 'La dirección del servicio Antigravity del proveedor, p. ej. https://cloudcode-pa.googleapis.com', + apiKey: 'Clave API del proveedor', + apiKeyHint: 'Clave API del servicio del proveedor', + pleaseEnterBaseUrl: 'Introduce la URL base del proveedor', + pleaseEnterApiKey: 'Introduce la clave API del proveedor' + }, + // OAuth flow + oauth: { + title: 'Autorización de la cuenta de Claude', + authMethod: 'Método de autorización', + manualAuth: 'Autorización manual', + cookieAutoAuth: 'Autorización automática con cookie', + cookieAutoAuthDesc: + 'Usa la sessionKey de claude.ai para completar la autorización OAuth automáticamente, sin abrir el navegador a mano.', + sessionKey: 'sessionKey', + keysCount: '{count} claves', + batchCreateAccounts: 'Se crearán {count} cuentas en bloque', + sessionKeyPlaceholder: + 'Una sessionKey por línea, p. ej.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...', + sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...', + howToGetSessionKey: 'Cómo conseguir la sessionKey', + step1: 'Inicia sesión en claude.ai en tu navegador', + step2: 'Pulsa F12 para abrir las herramientas de desarrollo', + step3: 'Ve a la pestaña Aplicación', + step4: 'Busca Cookies → https://claude.ai', + step5: 'Busca la fila con la clave sessionKey', + step6: 'Copia el valor', + sessionKeyFormat: 'La sessionKey suele empezar por sk-ant-sid01-', + startAutoAuth: 'Iniciar la autorización automática', + authorizing: 'Autorizando...', + followSteps: 'Sigue estos pasos para autorizar tu cuenta de Claude:', + step1GenerateUrl: 'Pulsa el botón de abajo para generar la URL de autorización', + generateAuthUrl: 'Generar la URL de autorización', + generating: 'Generando...', + regenerate: 'Regenerar', + step2OpenUrl: 'Abre la URL en tu navegador y completa la autorización', + openUrlDesc: + 'Abre la URL de autorización en una pestaña nueva, inicia sesión en tu cuenta de Claude y autoriza.', + proxyWarning: + 'Nota: si configuraste un proxy, asegúrate de que tu navegador use el mismo proxy para acceder a la página de autorización.', + step3EnterCode: 'Introduce el código de autorización', + authCodeDesc: + 'Cuando termine la autorización, la página mostrará un código. Cópialo y pégalo abajo:', + authCode: 'Código de autorización', + authCodePlaceholder: 'Pega aquí el código de autorización de la página de Claude...', + authCodeHint: 'Pega el código de autorización que copiaste de la página de Claude', + completeAuth: 'Completar la autorización', + verifying: 'Verificando...', + pleaseEnterSessionKey: 'Introduce al menos una sessionKey válida', + authFailed: 'Falló la autorización', + cookieAuthFailed: 'Falló la autorización con cookie', + keyAuthFailed: 'Clave {index}: {error}', + successCreated: 'Se crearon correctamente {count} cuenta(s)', + batchSuccess: 'Se crearon correctamente {count} cuenta(s)', + batchPartialSuccess: 'Éxito parcial: {success} correctas, {failed} fallidas', + batchFailed: 'Falló la creación en bloque', + // OpenAI specific + openai: { + title: 'Autorización de la cuenta de OpenAI', + followSteps: 'Sigue estos pasos para completar la autorización de la cuenta de OpenAI:', + step1GenerateUrl: 'Pulsa el botón de abajo para generar la URL de autorización', + generateAuthUrl: 'Generar la URL de autorización', + step2OpenUrl: 'Abre la URL en tu navegador y completa la autorización', + openUrlDesc: + 'Abre la URL de autorización en una pestaña nueva, inicia sesión en tu cuenta de OpenAI y autoriza.', + importantNotice: + 'Importante: la página puede tardar un rato en cargar después de autorizar. Ten paciencia. Cuando la barra de direcciones cambie a http://localhost..., la autorización habrá terminado.', + step3EnterCode: 'Introduce la URL o el código de autorización', + authCodeDesc: + 'Cuando termine la autorización y la URL de la página sea http://localhost:xxx/auth/callback?code=...:', + authCode: 'URL o código de autorización', + authCodePlaceholder: + 'Opción 1: copia la URL completa\n(http://localhost:xxx/auth/callback?code=...)\nOpción 2: copia solo el valor del parámetro code', + authCodeHint: + 'Puedes copiar la URL entera o solo el valor del parámetro code, el sistema lo detectará', + failedToGenerateUrl: 'No se pudo generar la URL de autorización de OpenAI', + failedToExchangeCode: 'No se pudo canjear el código de autorización de OpenAI', + failedToValidateRT: 'No se pudo validar el token de renovación', + errors: { + OPENAI_OAUTH_PROXY_REQUIRED: + 'No hay ningún proxy configurado y este servidor no pudo llegar a OpenAI directamente, así que falló la petición OAuth. Selecciona un proxy que pueda acceder a OpenAI e inténtalo de nuevo; si el código de autorización ha caducado, genera una URL nueva.' + }, + // Refresh Token auth + refreshTokenAuth: 'Introducir el RT a mano', + refreshTokenDesc: 'Introduce tus tokens de renovación de OpenAI ya existentes. Admite entrada en bloque (uno por línea). El sistema los validará y creará las cuentas automáticamente.', + refreshTokenPlaceholder: 'Pega tu token de renovación de OpenAI...\nAdmite varios, uno por línea', + mobileRefreshTokenAuth: 'Introducir el RT móvil a mano', + accessTokenAuth: 'Introducir el AT a mano', + codexSessionAuth: 'Importar auth.json o AT de OAuth de Codex', + codexSessionDesc: 'Pega un auth.json de OAuth de Codex o un accessToken. Las cuentas usan los ajustes del paso 1.', + codexSessionInputLabel: 'auth.json o accessToken de OAuth de Codex', + codexSessionPlaceholder: 'Se admiten varias líneas, un token u objeto auth.json por línea', + codexSessionHint: 'Las importaciones de sesión o token de acceso OAuth conservan su comportamiento de caducidad.', + codexSessionImportAndCreate: 'Importar y crear la cuenta', + codexSessionEmpty: 'Introduce un auth.json o un accessToken de Codex', + codexSessionImportFailed: 'No se pudo importar la cuenta de Codex', + codexSessionImportSuccess: 'Importación completada: creadas {created}, actualizadas {updated}, omitidas {skipped}', + codexSessionImportPartial: 'Éxito parcial: creadas {created}, actualizadas {updated}, omitidas {skipped}, fallidas {failed}', + agentIdentityAuth: 'auth.json de Agent Identity', + agentIdentityDesc: 'Importa un auth.json de Agent Identity de Codex. No se guarda ningún token de acceso ni de renovación OAuth.', + agentIdentityInputLabel: 'auth.json de Agent Identity', + agentIdentityPlaceholder: 'Pega un objeto auth.json de Agent Identity', + agentIdentityHint: 'El archivo debe usar auth_mode=agentIdentity. Las peticiones al proveedor se firman de forma dinámica.', + agentIdentityInvalid: 'Usa un auth.json de Codex con auth_mode=agentIdentity.', + codexPatAuth: 'Token de acceso personal de Codex', + codexPatDesc: 'Introduce un token de acceso personal at- de Codex. El sistema lo valida con el whoami de OpenAI antes de crear la cuenta.', + codexPatInputLabel: 'PAT de Codex', + codexPatPlaceholder: 'at-...', + codexPatHint: 'Es un modo de autenticación aparte. No guarda refresh_token ni escribe una caducidad de access_token OAuth.', + codexPatImportAndCreate: 'Validar y crear la cuenta con PAT de Codex', + codexPatEmpty: 'Introduce un token de acceso personal de Codex', + codexPatImportFailed: 'No se pudo crear la cuenta con PAT de Codex', + sessionTokenAuth: 'Introducir el ST a mano', + sessionTokenDesc: 'Introduce tus tokens de sesión ya existentes. Admite entrada en bloque (uno por línea). El sistema los validará y creará las cuentas automáticamente.', + sessionTokenPlaceholder: 'Pega tu token de sesión...\nAdmite varios, uno por línea', + sessionTokenRawLabel: 'Entrada en bruto', + sessionTokenRawPlaceholder: 'Pega el contenido en bruto de /api/auth/session o el token de sesión...', + sessionTokenRawHint: 'Puedes pegar el JSON completo. El sistema extraerá el ST y el AT automáticamente.', + openSessionUrl: 'Abrir la URL de consulta', + copySessionUrl: 'Copiar la URL', + sessionUrlHint: 'Esta URL suele devolver el AT. Si no aparece sessionToken, copia __Secure-next-auth.session-token de las cookies del navegador como ST.', + parsedSessionTokensLabel: 'ST extraídos', + parsedSessionTokensEmpty: 'No se extrajo ningún ST. Revisa lo que has introducido.', + parsedAccessTokensLabel: 'AT extraídos', + validating: 'Validando...', + validateAndCreate: 'Validar y crear la cuenta', + pleaseEnterRefreshToken: 'Introduce el token de renovación', + pleaseEnterSessionToken: 'Introduce el token de sesión' + }, + grok: { + title: 'Autorización de la cuenta de Grok', + followSteps: 'Sigue estos pasos para autorizar tu cuenta de xAI/Grok:', + step1GenerateUrl: 'Genera la URL de autorización de xAI', + generateAuthUrl: 'Generar la URL de autorización', + step2OpenUrl: 'Abre la URL en tu navegador y completa la autorización', + openUrlDesc: 'Abre la URL de autorización en una pestaña nueva, inicia sesión en xAI y autoriza el acceso a la API.', + importantNotice: 'Cuando el navegador llegue a la URL de retorno local, copia aquí la URL completa o el parámetro code.', + step3EnterCode: 'Introduce la URL o el código de autorización', + authCodeDesc: 'Tras autorizar, pega la URL de retorno, la cadena de consulta o el código:', + authCode: 'URL o código de autorización', + authCodePlaceholder: 'Pega la URL de retorno completa, la cadena ?code=... o el valor del código', + authCodeHint: 'Se admiten URLs de retorno completas, cadenas de consulta y códigos sueltos.', + refreshTokenAuth: 'Introducir el RT a mano', + refreshTokenDesc: 'Introduce tus tokens de renovación de xAI ya existentes. Admite entrada en bloque, uno por línea.', + refreshTokenPlaceholder: 'Pega tu token de renovación de xAI...\nAdmite varios, uno por línea', + ssoCookieAuth: 'Importar cookie SSO', + ssoCookieDesc: 'Pega una clave SSO de la web de Grok por línea. El servidor completará el flujo de dispositivo de xAI y las convertirá en credenciales OAuth de Grok Build.', + ssoCookieLabel: 'Clave SSO de la web de Grok', + ssoCookiePlaceholder: 'Una clave SSO por línea\nAdmite varias, una por línea', + ssoCookieHint: 'Una clave SSO por línea. Varias claves se importan con 3 a la vez; cuenta con unos 90 segundos por lote. Usa un proxy de la región adecuada si hace falta.', + emailPasswordAuth: 'Correo + contraseña', + emailPasswordDesc: + 'Inicia sesión con el correo y la contraseña de la web de Grok. El servidor usa la contraseña solo para obtener una cookie SSO temporal y la convierte en credenciales OAuth de Build. Ni la contraseña ni el SSO en bruto se guardan en la cuenta.', + emailPasswordInputLabel: 'correo----contraseña', + emailPasswordPlaceholder: "usuario{'@'}ejemplo.com----tu-contrasena\nSe admiten varias líneas", + emailPasswordHint: + 'Formato: correo----contraseña (la contraseña puede llevar guiones). Requiere claves de YesCaptcha; usa un proxy de la región adecuada si hace falta.', + pleaseEnterPassword: 'Introduce correo----contraseña (uno por línea)', + pleaseEnterSSOToken: 'Introduce un token SSO', + failedToValidateSSO: 'No se pudo validar el SSO de Grok', + failedToAuthorizePassword: 'Falló la autorización de Grok con contraseña', + convertingSSO: 'Convirtiendo...', + convertSSOAndCreate: 'Convertir y crear la cuenta', + validating: 'Validando...', + validateAndCreate: 'Validar y crear la cuenta', + pleaseEnterRefreshToken: 'Introduce el token de renovación', + failedToGenerateUrl: 'No se pudo generar la URL de autorización de Grok', + missingExchangeParams: 'Falta el código de autorización, el estado o la sesión OAuth', + failedToExchangeCode: 'No se pudo canjear el código de autorización de Grok', + failedToValidateRT: 'No se pudo validar el token de renovación de Grok', + failedToConvertSSO: 'No se pudo convertir la cookie SSO de Grok', + errors: { + GROK_OAUTH_SESSION_NOT_FOUND: + 'No se encontró la sesión OAuth de Grok o ha caducado. Genera una URL de autorización nueva y pega la URL de retorno más reciente.', + GROK_OAUTH_INVALID_STATE: + 'El estado OAuth de Grok no coincide con esta sesión. Pega la URL de retorno del mismo enlace de autorización generado.', + GROK_OAUTH_STATE_REQUIRED: + 'A la URL de retorno le falta el estado OAuth. Pega la URL de retorno completa, no solo el código.', + GROK_OAUTH_CODE_REQUIRED: + 'Falta el código de autorización de Grok. Pega la URL de retorno completa, la cadena de consulta o el valor del código.', + GROK_OAUTH_NO_REFRESH_TOKEN: + 'La respuesta de Grok no incluyó un token de renovación. Genera una URL de autorización nueva y vuelve a aprobar el acceso sin conexión.', + GROK_OAUTH_PROXY_NOT_AVAILABLE: + 'No se puede consultar el proxy del OAuth de Grok. Revisa el proxy seleccionado e inténtalo de nuevo.', + GROK_OAUTH_PROXY_NOT_FOUND: + 'No se encontró el proxy seleccionado. Elige uno disponible e inténtalo de nuevo.' + }, + oauthOnlyHint: 'La compatibilidad inicial con Grok cubre solo el tráfico de texto y razonamiento de la API Responses respaldado por suscripción OAuth.' + }, + // Gemini specific + gemini: { + title: 'Autorización de la cuenta de Gemini', + followSteps: 'Sigue estos pasos para autorizar tu cuenta de Gemini:', + step1GenerateUrl: 'Genera la URL de autorización', + generateAuthUrl: 'Generar la URL de autorización', + projectIdLabel: 'ID de proyecto (opcional)', + projectIdPlaceholder: 'p. ej. mi-proyecto-gcp o cloud-ai-companion-xxxxx', + projectIdHint: + 'Déjalo vacío para detectarlo automáticamente tras canjear el código. Si la detección falla, rellénalo y genera de nuevo la URL de autorización para volver a intentarlo.', + howToGetProjectId: 'Cómo conseguirlo', + step2OpenUrl: 'Abre la URL en tu navegador y completa la autorización', + openUrlDesc: + 'Abre la URL de autorización en una pestaña nueva, inicia sesión en tu cuenta de Google y autoriza.', + step3EnterCode: 'Introduce la URL o el código de autorización', + authCodeDesc: + 'Tras autorizar, copia la URL de retorno (recomendado) o solo el código y pégalo abajo.', + authCode: 'URL de retorno o código', + authCodePlaceholder: + 'Opción 1 (recomendada): pega la URL de retorno\nOpción 2: pega solo el valor del código', + authCodeHint: 'El sistema extraerá automáticamente code y state de la URL.', + redirectUri: 'URI de redirección', + redirectUriHint: + 'Debe estar configurada en tu cliente OAuth de Google y coincidir exactamente.', + confirmRedirectUri: + 'He configurado esta URI de redirección en el cliente OAuth de Google (debe coincidir exactamente)', + invalidRedirectUri: 'La URI de redirección debe ser una URL http(s) válida', + redirectUriNotConfirmed: 'Confirma que la URI de redirección está bien configurada', + missingRedirectUri: 'Falta la URI de redirección', + failedToGenerateUrl: 'No se pudo generar la URL de autorización de Gemini', + missingExchangeParams: 'Falta el código de autorización, el ID de sesión o el estado', + failedToExchangeCode: 'No se pudo canjear el código de autorización de Gemini', + missingProjectId: 'No se pudo obtener el ID de proyecto de GCP: tu cuenta de Google no está vinculada a un proyecto de GCP activo. Activa GCP y vincula una tarjeta en la consola de Google Cloud, o introduce el ID de proyecto a mano durante la autorización.', + modelPassthrough: 'Paso directo de modelos de Gemini', + modelPassthroughDesc: + 'Todas las peticiones de modelo se reenvían directamente a la API de Gemini sin restricciones ni correspondencias de modelo.', + stateWarningTitle: 'Nota', + stateWarningDesc: 'Recomendado: pega la URL de retorno completa (incluye code y state).', + oauthTypeLabel: 'Tipo de OAuth', + needsProjectId: 'OAuth integrado (Code Assist)', + needsProjectIdDesc: 'Requiere un proyecto de GCP y su ID', + noProjectIdNeeded: 'OAuth personalizado (AI Studio)', + noProjectIdNeededDesc: 'Requiere un cliente OAuth configurado por el administrador', + aiStudioNotConfiguredShort: 'Sin configurar', + aiStudioNotConfiguredTip: + 'El OAuth de AI Studio no está configurado: define GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET y añade la URI de redirección http://localhost:1455/auth/callback (los ámbitos de la pantalla de consentimiento deben incluir https://www.googleapis.com/auth/generative-language.retriever)', + aiStudioNotConfigured: + 'El OAuth de AI Studio no está configurado: define GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET y añade la URI de redirección http://localhost:1455/auth/callback' + }, + // Antigravity specific + antigravity: { + title: 'Autorización de la cuenta de Antigravity', + followSteps: 'Sigue estos pasos para autorizar tu cuenta de Antigravity:', + step1GenerateUrl: 'Genera la URL de autorización', + generateAuthUrl: 'Generar la URL de autorización', + step2OpenUrl: 'Abre la URL en tu navegador y completa la autorización', + openUrlDesc: 'Abre la URL de autorización en una pestaña nueva, inicia sesión en tu cuenta de Google y autoriza.', + importantNotice: + 'Importante: la página puede tardar un rato en cargar después de autorizar. Ten paciencia. Cuando la barra de direcciones muestre http://localhost..., la autorización habrá terminado.', + step3EnterCode: 'Introduce la URL o el código de autorización', + authCodeDesc: + 'Tras autorizar, cuando la URL de la página sea http://localhost:xxx/auth/callback?code=...:', + authCode: 'URL o código de autorización', + authCodePlaceholder: + 'Opción 1: copia la URL completa\n(http://localhost:xxx/auth/callback?code=...)\nOpción 2: copia solo el valor del parámetro code', + authCodeHint: 'Puedes copiar la URL entera o solo el valor del parámetro code, el sistema lo detectará', + failedToGenerateUrl: 'No se pudo generar la URL de autorización de Antigravity', + missingExchangeParams: 'Falta el código, el ID de sesión o el estado', + failedToExchangeCode: 'No se pudo canjear el código de autorización de Antigravity', + // Refresh Token auth + refreshTokenAuth: 'RT manual', + refreshTokenDesc: 'Introduce tu token de renovación de Antigravity ya existente. Admite entrada en bloque (uno por línea). El sistema los validará y creará las cuentas automáticamente.', + refreshTokenPlaceholder: 'Pega tu token de renovación de Antigravity...\nAdmite varios tokens, uno por línea', + validating: 'Validando...', + validateAndCreate: 'Validar y crear', + pleaseEnterRefreshToken: 'Introduce el token de renovación', + failedToValidateRT: 'No se pudo validar el token de renovación' + } + }, // Gemini specific (platform-wide) + gemini: { + helpButton: 'Ayuda', + helpDialog: { + title: 'Guía de uso de Gemini', + apiKeySection: 'Enlaces de claves API' + }, + modelPassthrough: 'Paso directo de modelos de Gemini', + modelPassthroughDesc: + 'Todas las peticiones de modelo se reenvían directamente a la API de Gemini sin restricciones ni correspondencias de modelo.', + baseUrlHint: 'Déjalo por defecto para la API oficial de Gemini', + apiKeyHint: 'Tu clave API de Gemini (empieza por AIza)', + tier: { + label: 'Nivel de la cuenta', + hint: 'Consejo: el sistema intentará detectar el nivel automáticamente; si no puede o falla, se usa el nivel que selecciones como respaldo (cuota simulada).', + aiStudioHint: + 'Las cuotas de AI Studio son por modelo (Pro y Flash se limitan por separado). Si tienes la facturación activada, elige el de pago por uso.', + googleOne: { + free: 'Google One gratuito', + pro: 'Google One Pro', + ultra: 'Google One Ultra' + }, + gcp: { + standard: 'GCP estándar', + enterprise: 'GCP empresarial' + }, + aiStudio: { + free: 'Google AI gratuito', + paid: 'Google AI de pago por uso' + } + }, + accountType: { + oauthTitle: 'OAuth (Gemini)', + oauthDesc: 'Autoriza con tu cuenta de Google y elige un tipo de OAuth.', + apiKeyTitle: 'Clave API (AI Studio)', + apiKeyDesc: 'La configuración más rápida. Usa una clave API AIza.', + apiKeyNote: + 'Ideal para pruebas ligeras. El nivel gratuito tiene límites estrictos y los datos pueden usarse para entrenar.', + apiKeyLink: 'Conseguir una clave API', + quotaLink: 'Guía de cuotas' + }, + oauthType: { + builtInTitle: 'OAuth integrado (Gemini CLI / Code Assist)', + builtInDesc: 'Usa el client ID integrado de Google. No requiere configuración del administrador.', + builtInRequirement: 'Requiere un proyecto de GCP y su ID.', + googleOneDesc: 'Cuenta personal con cuota de suscripción de Google One', + codeAssistDesc: 'De nivel empresarial, requiere un proyecto de GCP', + codeAssistRequirement: 'Requiere un proyecto de GCP activo con la facturación habilitada', + showAdvanced: 'Mostrar las opciones avanzadas (cliente OAuth propio)', + hideAdvanced: 'Ocultar las opciones avanzadas (cliente OAuth propio)', + gcpProjectLink: 'Crear un proyecto', + customTitle: 'OAuth personalizado (OAuth de AI Studio)', + customDesc: 'Usa un cliente OAuth configurado por el administrador para gestionarlo desde la organización.', + customRequirement: 'El administrador debe configurar el Client ID y añadirte como usuario de prueba.', + badges: { + recommended: 'Recomendado', + highConcurrency: 'Alta concurrencia', + individuals: 'Recomendado para particulares', + noGcp: 'Sin GCP', + enterprise: 'Usuarios empresariales', + noAdmin: 'Sin configuración de administrador', + orgManaged: 'Gestionado por la organización', + adminRequired: 'Requiere administrador' + } + }, + setupGuide: { + title: 'Lista de comprobación de Gemini', + checklistTitle: 'Comprobaciones', + checklistItems: { + usIp: 'Usa una IP de Estados Unidos y asegúrate de que el país de tu cuenta sea Estados Unidos.', + age: 'La cuenta debe ser de mayores de 18 años.' + }, + activationTitle: 'Activación en un clic', + activationItems: { + geminiWeb: 'Activa Gemini Web para evitar el error «User not initialized».', + gcpProject: 'Activa un proyecto de GCP y consigue su ID para Code Assist.' + }, + links: { + countryCheck: 'Comprobar el país asociado', + countryChange: 'Cambiar el país asociado', + geminiWebActivation: 'Activar Gemini Web', + gcpProject: 'Abrir la consola de GCP' + } + }, + quotaPolicy: { + title: 'Política de cuotas y límites de Gemini (referencia)', + note: 'Nota: Gemini no ofrece una API oficial para consultar la cuota. La «cuota diaria» que se muestra aquí es una estimación que simula el sistema según el nivel de la cuenta, solo como referencia para planificar. Consulta los errores oficiales de Google para conocer los límites reales.', + columns: { + channel: 'Canal de autenticación', + account: 'Estado de la cuenta', + limits: 'Política de límites', + docs: 'Documentación oficial' + }, + docs: { + codeAssist: 'Cuotas de Code Assist', + aiStudio: 'Precios de AI Studio', + vertex: 'Cuotas de Vertex AI' + }, + simulatedNote: 'Cuota simulada, solo como referencia', + rows: { + googleOne: { + channel: 'OAuth de Google One (particulares / Code Assist para particulares)', + limitsFree: 'Bolsa compartida: 1000 RPD / 60 RPM', + limitsPro: 'Bolsa compartida: 1500 RPD / 120 RPM', + limitsUltra: 'Bolsa compartida: 2000 RPD / 120 RPM' + }, + gcp: { + channel: 'OAuth de Code Assist en GCP (empresas)', + limitsStandard: 'Bolsa compartida: 1500 RPD / 120 RPM', + limitsEnterprise: 'Bolsa compartida: 2000 RPD / 120 RPM' + }, + cli: { + channel: 'Gemini CLI (inicio de sesión oficial de Google / Code Assist)', + free: 'Cuenta de Google gratuita', + premium: 'Google One AI Premium', + limitsFree: 'RPD ~1000; RPM ~60 (flexible)', + limitsPremium: 'RPD ~1500+; RPM ~60+ (cola prioritaria)' + }, + gcloud: { + channel: 'Code Assist en GCP (gcloud auth)', + account: 'Sin suscripción a Code Assist', + limits: 'RPD ~1000; RPM ~60 (versión preliminar)' + }, + aiStudio: { + channel: 'Clave API u OAuth de AI Studio', + free: 'Sin facturación (nivel gratuito)', + paid: 'Con facturación (pago por uso)', + limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)', + limitsPaid: 'RPD ilimitado; RPM 1000 (Pro) / 2000 (Flash) (por modelo)' + }, + customOAuth: { + channel: 'Cliente OAuth propio (GCP)', + free: 'Proyecto sin facturación', + paid: 'Proyecto con facturación', + limitsFree: 'RPD 50; RPM 2 (cuota del proyecto)', + limitsPaid: 'RPD ilimitado; RPM 1000+ (cuota del proyecto)' + } + } + }, + rateLimit: { + ok: 'Sin límite alcanzado', + unlimited: 'Ilimitado', + limited: 'Límite alcanzado {time}', + now: 'ahora' + } + }, + // Re-Auth Modal + reAuthorizeAccount: 'Volver a autorizar la cuenta', + claudeCodeAccount: 'Cuenta de Claude Code', + openaiAccount: 'Cuenta de OpenAI', + geminiAccount: 'Cuenta de Gemini', + antigravityAccount: 'Cuenta de Antigravity', + grokAccount: 'Cuenta de Grok', + inputMethod: 'Método de entrada', + reAuthorizedSuccess: 'Cuenta autorizada de nuevo correctamente', + // Test Modal + testAccountConnection: 'Probar la conexión de la cuenta', + errorPrefix: 'Error: {message}', + imagePreviewAlt: 'Imagen de prueba {index}', + imageLightboxAlt: 'Vista previa de la imagen', + account: 'Cuenta', + readyToTest: 'Listo para probar. Pulsa «Iniciar la prueba» para empezar...', + connectingToApi: 'Conectando con la API...', + testCompleted: '¡Prueba completada correctamente!', + testFailed: 'La prueba falló', + connectedToApi: 'Conectado con la API', + usingModel: 'Usando el modelo: {model}', + sendingTestMessage: 'Enviando el mensaje de prueba: «hi»', + sendingImageRequest: 'Enviando la petición de prueba de generación de imagen...', + response: 'Respuesta:', + startTest: 'Iniciar la prueba', + testing: 'Probando...', + retry: 'Reintentar', + copyOutput: 'Copiar la salida', + outputCopied: 'Salida copiada', + startingTestForAccount: 'Iniciando la prueba de la cuenta: {name}', + testAccountTypeLabel: 'Tipo de cuenta: {type}', + selectTestModel: 'Selecciona el modelo de prueba', + testModel: 'Modelo de prueba', + testPrompt: 'Prompt: «hi»', + imagePromptLabel: 'Prompt de imagen', + imagePromptPlaceholder: 'Ejemplo: Genera una pegatina en pixel-art de un gato naranja astronauta sobre fondo liso.', + imagePromptDefault: 'Genera una pegatina de un gato naranja astronauta sobre un fondo pastel limpio.', + imageTestHint: + 'Llama a /v1/images/generations de forma independiente y muestra abajo la imagen devuelta.', + imageTestMode: 'Modo: prueba de generación de imagen', + videoPromptLabel: 'Prompt de vídeo', + videoPromptPlaceholder: 'Ejemplo: Una pelota roja que rebota una vez en un suelo blanco, movimiento corto y sencillo.', + videoPromptDefault: 'Una pelota roja que rebota una vez en un suelo blanco, movimiento corto y sencillo.', + videoTestHint: + 'Llama a /v1/videos/generations de forma independiente, consulta hasta que termine y descarga el vídeo para verlo en la página.', + videoTestMode: 'Modo: prueba de generación de vídeo', + sendingVideoRequest: 'Enviando la petición de generación de vídeo...', + imagePreview: 'Imágenes generadas:', + imageReceived: 'Imagen de prueba n.º {count} recibida', + audioPreview: 'Audio generado:', + audioReceived: 'Audio de prueba n.º {count} recibido', + videoPreview: 'Vídeo generado:', + videoReceived: 'Vídeo de prueba n.º {count} recibido', + // Stats Modal + viewStats: 'Ver las estadísticas', + usageStatistics: 'Estadísticas de consumo', + last30DaysUsage: 'Estadísticas de consumo de los últimos 30 días (según los días de uso real)', + stats: { + totalCost: 'Coste total de 30 días', + accumulatedCost: 'Coste acumulado', + standardCost: 'Estándar', + totalRequests: 'Peticiones totales de 30 días', + totalCalls: 'Llamadas totales a la API', + avgDailyCost: 'Coste medio diario', + basedOnActualDays: 'Según {days} días de uso real', + avgDailyRequests: 'Peticiones medias diarias', + avgDailyUsage: 'Consumo medio diario', + todayOverview: 'Resumen de hoy', + cost: 'Coste', + requests: 'Peticiones', + tokens: 'Tokens', + highestCostDay: 'Día de mayor coste', + highestRequestDay: 'Día de más peticiones', + date: 'Fecha', + accumulatedTokens: 'Tokens acumulados', + totalTokens: 'Total de 30 días', + dailyAvgTokens: 'Media diaria', + performance: 'Rendimiento', + avgResponseTime: 'Respuesta media', + daysActive: 'Días activa', + recentActivity: 'Actividad reciente', + todayRequests: 'Peticiones de hoy', + todayTokens: 'Tokens de hoy', + todayCost: 'Coste de hoy', + usageTrend: 'Tendencia de coste y peticiones de 30 días', + noData: 'No hay datos de consumo para esta cuenta' + }, + usageWindow: { + statsTitle: 'Estadísticas de consumo de la ventana de 5 horas', + statsTitleDaily: 'Estadísticas de consumo diario', + geminiProDaily: 'Pro', + geminiFlashDaily: 'Flash', + gemini3Pro: 'G3P', + gemini3Flash: 'G3F', + gemini3Image: 'G31FI', + claude: 'Claude', + grokRequests: 'Pet.', + grokTokens: 'Tok.', + grokFreeQuota24hHint: 'Estimado a partir del consumo local de tokens en la ventana móvil de 24 horas (límite de {limit})', + grokWeeklyUsage: 'Semanal {percent} %', + grokUsed: 'Usado $', + grokBalance: 'Saldo $', + grokPrepaid: 'Saldo prepagado', + grokMonthlyLimit: 'Usado mensual / límite (USD)', + grokOverage: 'Exceso onDemandUsed/onDemandCap', + grokOverageShort: 'OD $', + grokUnknown: 'La cuota de Grok es desconocida hasta que la primera respuesta del proveedor incluya las cabeceras de límite de xAI.', + grokRetryAfter: 'Reintentar en {time}', + grokProbe: 'Detectar', + grokProbeTooltip: 'Envía una petición mínima a Responses de xAI y lee las cabeceras de cuota', + grokResetUnsupported: 'Reinicio no admitido', + grokResetUnsupportedTooltip: 'xAI no expone créditos de reinicio para las cuentas OAuth de Grok', + grokNoHeaders: 'No se han visto cabeceras de cuota', + grokLastStatus: 'Estado {status}', + grokLastProbe: 'Detección {time}', + grokLastHeadersSeen: 'Cabeceras {time}', + passiveSampled: 'Pasivo', + activeQuery: 'Consulta' + }, + openaiQuotaReset: { + count: 'Créditos', + reset: 'Reiniciar', + countTooltipLoad: 'Pulsa para cargar el número de créditos de reinicio disponibles', + countTooltipRefresh: 'Pulsa para actualizar el número de créditos de reinicio disponibles', + resetTooltipReady: 'Consume 1 crédito de reinicio para restaurar la ventana de inmediato', + resetTooltipNeedQuery: 'Pulsa antes en Créditos para cargar el número disponible', + resetTooltipNoCredits: 'No hay créditos de reinicio disponibles', + resetTooltipShadow: 'Las cuentas sombra de spark no pueden reiniciar créditos; hazlo en la cuenta padre', + expiresAt: 'Caduca {time}', + expiresAtFull: 'El crédito de reinicio caduca el {time}', + expandExpirations: 'Ver las otras {count} caducidad(es) de créditos de reinicio', + collapseExpirations: 'Ocultar las caducidades de los créditos de reinicio', + expirationDetails: 'Detalle de caducidad de los créditos de reinicio', + noCreditsAvailable: 'No hay créditos de reinicio disponibles', + resetSuccess: 'Se reiniciaron {windows} ventana(s); los créditos y el estado de la cuenta se actualizaron', + resetCacheRefreshFailed: 'La ventana se reinició y el estado de la cuenta se recuperó, pero no se pudo volver a leer el número de créditos de reinicio. Consúltalo de nuevo.', + resetAccountRecoveryFailed: 'La ventana se reinició, pero falló la recuperación del estado de la cuenta. Recupéralo a mano.', + resetAccountRefreshFailed: 'La ventana, el estado de la cuenta y la caché de créditos se actualizaron, pero no se pudieron cargar los datos más recientes de la cuenta.', + refreshCachePersistFailed: 'Se muestra el número en vivo, pero no se pudieron obtener sus detalles de caducidad, así que se conservaron los de la caché.', + confirmTitle: 'Confirmar el reinicio del límite semanal', + confirmMessage: 'Esto consumirá 1 crédito de reinicio para restaurar la ventana actual de inmediato (quedan {count}). No se puede deshacer. ¿Continuar?' + }, + tier: { + free: 'Gratuito', + pro: 'Pro', + ultra: 'Ultra', + aiPremium: 'AI Premium', + standard: 'Estándar', + basic: 'Básico', + personal: 'Personal', + unlimited: 'Ilimitado' + }, + ineligibleWarning: + 'Esta cuenta no cumple los requisitos de Antigravity, pero el reenvío por API sigue funcionando. Úsala bajo tu responsabilidad.', + forbidden: 'Prohibida', + forbiddenValidation: 'Requiere verificación', + forbiddenViolation: 'Bloqueada por infracción', + openVerification: 'Abrir el enlace de verificación', + copyLink: 'Copiar el enlace', + linkCopied: 'Enlace copiado', + needsReauth: 'Requiere volver a autorizar', + rateLimited: 'Límite alcanzado', + usageError: 'Error al consultar' + }, + + // Scheduled Tests +} diff --git a/frontend/src/i18n/locales/es/admin/audit.ts b/frontend/src/i18n/locales/es/admin/audit.ts new file mode 100644 index 00000000000..5a10ed44b1c --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/audit.ts @@ -0,0 +1,52 @@ +export default { + audit: { + title: 'Registros de auditoría', + description: 'Registra las operaciones de administración realizadas por administradores y usuarios. De las credenciales enviadas en las cabeceras solo se conservan los primeros y últimos caracteres, y el contenido de las peticiones se censura. Las entradas no se pueden borrar una por una; vaciarlas todas requiere verificación en dos pasos.', + clearAll: 'Vaciar todo', + empty: 'Todavía no hay registros de auditoría', + loadFailed: 'No se pudieron cargar los registros de auditoría', + filters: { + all: 'Todos', + q: 'Palabra clave', + qPlaceholder: 'Ruta / acción / correo del actor', + actorEmail: 'Correo del actor', + action: 'Acción', + clientIp: 'IP del cliente', + method: 'Método', + authMethod: 'Método de autenticación', + result: 'Resultado', + resultSuccess: 'Correcto', + resultFailure: 'Fallido', + startTime: 'Hora de inicio', + endTime: 'Hora de fin' + }, + columns: { + time: 'Hora', + actor: 'Actor', + action: 'Acción', + method: 'Método', + result: 'Resultado', + clientIp: 'IP del cliente', + detail: 'Detalle' + }, + detail: { + title: 'Detalle del registro de auditoría', + actorRole: 'Rol', + methodPath: 'Método / Ruta', + latency: 'Latencia', + requestId: 'ID de la petición', + credential: 'Credencial (enmascarada)', + userAgent: 'User-Agent', + requestBody: 'Cuerpo de la petición (censurado)', + extra: 'Adicional' + }, + clearConfirm: { + title: 'Vaciar todos los registros de auditoría', + message: 'Esto elimina de forma permanente todos los registros de auditoría y no se puede deshacer. La propia acción de vaciado queda registrada. ¿Continuar?', + totpTitle: 'Introduce el código de dos pasos', + totpHint: 'Vaciar los registros de auditoría requiere una verificación TOTP reciente.', + success: 'Se vaciaron {count} registro(s) de auditoría', + failed: 'No se pudieron vaciar los registros de auditoría' + } + } +} diff --git a/frontend/src/i18n/locales/es/admin/channels.ts b/frontend/src/i18n/locales/es/admin/channels.ts new file mode 100644 index 00000000000..9d594713693 --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/channels.ts @@ -0,0 +1,729 @@ +export default { + availableChannels: { + title: 'Canales disponibles', + description: 'Vista agregada: cada canal con sus grupos vinculados y los modelos que admite (comodines expandidos)', + searchPlaceholder: 'Buscar canales o modelos...', + columns: { + name: 'Canal', + status: 'Estado', + billingSource: 'Modelo usado para facturar', + groups: 'Grupos vinculados', + supportedModels: 'Modelos admitidos' + }, + empty: 'Sin datos', + noGroups: 'Sin grupos vinculados', + noModels: 'No hay ninguna correspondencia de modelos configurada', + noPricing: 'Precios sin configurar', + statusActive: 'Activo', + statusDisabled: 'Desactivado', + billingSource: { + requested: 'Modelo solicitado', + upstream: 'Modelo del proveedor', + channel_mapped: 'Modelo asignado por el canal' + }, + pricing: { + billingMode: 'Modo de facturación', + billingModeToken: 'Por token', + billingModePerRequest: 'Por petición', + billingModeImage: 'Por imagen', + billingModeVideo: 'Por vídeo', + inputPrice: 'Entrada', + outputPrice: 'Salida', + cacheWritePrice: 'Escritura de caché', + cacheReadPrice: 'Lectura de caché', + imageOutputPrice: 'Salida de imagen', + perRequestPrice: 'Por petición', + intervals: 'Precios por tramos', + unitPerMillion: '/ 1M tokens', + unitPerRequest: '/ petición' + } + }, + + // Channel Management + channels: { + title: 'Gestión de canales', + description: 'Gestiona los canales y los precios personalizados por modelo', + searchChannels: 'Buscar canales...', + createChannel: 'Crear un canal', + editChannel: 'Editar el canal', + deleteChannel: 'Eliminar el canal', + statusActive: 'Activo', + statusDisabled: 'Desactivado', + allStatus: 'Todos los estados', + groupsUnit: 'grupos', + pricingUnit: 'reglas de precio', + noChannelsYet: 'Todavía no hay canales', + createFirstChannel: 'Crea tu primer canal para gestionar los precios de los modelos', + loadError: 'No se pudieron cargar los canales', + createSuccess: 'Canal creado', + updateSuccess: 'Canal actualizado', + deleteSuccess: 'Canal eliminado', + createError: 'No se pudo crear el canal', + updateError: 'No se pudo actualizar el canal', + deleteError: 'No se pudo eliminar el canal', + nameRequired: 'Introduce un nombre para el canal', + duplicateModels: 'El modelo «{0}» aparece en varias entradas de precio', + modelConflict: 'Los patrones de modelo «{model1}» y «{model2}» entran en conflicto: sus rangos de coincidencia se solapan. Los nombres de modelo se comparan sin distinguir mayúsculas, así que una entrada existente ya cubre todas las variantes; no hace falta añadirla por separado.', + mappingConflict: 'Los patrones de origen «{model1}» y «{model2}» entran en conflicto: sus rangos de coincidencia se solapan. Los patrones de origen se comparan sin distinguir mayúsculas, así que una entrada existente ya cubre todas las variantes.', + intervalValidation: { + negativeMin: 'Tramo n.º {index}: el número mínimo de tokens ({value}) no puede ser negativo', + maxPositive: 'Tramo n.º {index}: el número máximo de tokens ({value}) debe ser mayor que 0', + maxGreaterThanMin: 'Tramo n.º {index}: el número máximo de tokens ({max}) debe ser mayor que el mínimo ({min})', + negativePrice: 'Tramo n.º {index}: {field} no puede ser negativo', + unboundedLast: 'Tramo n.º {index}: un tramo sin límite (número máximo de tokens vacío) debe ir el último', + overlap: 'Los tramos n.º {previousIndex} y n.º {currentIndex} se solapan: el límite superior anterior ({previousMax}) es mayor que el límite inferior actual ({currentMin})', + price: { + inputPrice: 'el precio de entrada', + outputPrice: 'el precio de salida', + cacheWritePrice: 'el precio de escritura de caché', + cacheReadPrice: 'el precio de lectura de caché', + perRequestPrice: 'el precio por petición' + } + }, + deleteConfirm: '¿Seguro que quieres eliminar el canal «{name}»? No se puede deshacer.', + columns: { + name: 'Nombre', + description: 'Descripción', + status: 'Estado', + groups: 'Grupos', + pricing: 'Precios', + createdAt: 'Creado', + actions: 'Acciones' + }, + billingMode: { + token: 'Por token', + perRequest: 'Por petición', + image: 'Imagen (por petición)', + video: 'Vídeo (por segundo)' + }, + form: { + name: 'Nombre', + namePlaceholder: 'Introduce el nombre del canal', + description: 'Descripción', + descriptionPlaceholder: 'Descripción opcional', + status: 'Estado', + groups: 'Grupos asociados', + noGroupsAvailable: 'No hay grupos disponibles', + inOtherChannel: 'En «{name}»', + modelPricing: 'Precios por modelo', + models: 'Modelos', + modelsPlaceholder: 'Escribe el nombre completo del modelo y pulsa Intro', + modelInputHint: 'Pulsa Intro para añadir; admite pegar para importar en bloque.', + billingMode: 'Modo de facturación', + defaultPrices: 'Precios por defecto (se usan cuando ningún tramo coincide)', + inputPrice: 'Entrada', + outputPrice: 'Salida', + cacheWritePrice: 'Escritura de caché', + cacheReadPrice: 'Lectura de caché', + cacheWritePriceShort: 'Escr. caché', + cacheReadPriceShort: 'Lect. caché', + imageInputPrice: 'Entrada de imagen', + imageTokenPrice: 'Salida de imagen', + imageOutputPrice: 'Precio de salida de imagen', + pricePlaceholder: 'Por defecto', + intervals: 'Tramos de contexto (opcional)', + minTokens: 'Mín.', + maxTokens: 'Máx.', + inclusive: '(incluido)', + addInterval: 'Añadir un tramo', + requestTiers: 'Tramos por petición', + imageTiers: 'Tramos de imagen (por petición)', + videoTiers: 'Tramos por resolución de vídeo (por segundo)', + addTier: 'Añadir un tramo', + noTiersYet: 'Todavía no hay tramos. Pulsa añadir para configurar el precio por petición.', + noPricingRules: 'Todavía no hay reglas de precio. Pulsa «Añadir» para crear una.', + perRequestPrice: 'Precio por petición', + perRequestPriceRequired: 'El modo de facturación por petición o por imagen necesita un precio por petición o tramos de facturación', + tierLabel: 'Tramo', + resolution: 'Resolución', + modelMapping: 'Correspondencia de modelos', + modelMappingHint: 'Convierte los nombres de modelo solicitados en los nombres reales. Se aplica antes de la correspondencia de la cuenta.', + noMappingRules: 'No hay reglas de correspondencia. Pulsa «Añadir» para crear una.', + mappingSource: 'Modelo de origen', + mappingTarget: 'Modelo de destino', + billingModelSource: 'Modelo de facturación', + billingModelSourceChannelMapped: 'Facturar por el modelo asignado por el canal', + billingModelSourceRequested: 'Facturar por el modelo solicitado', + billingModelSourceUpstream: 'Facturar por el modelo final del proveedor', + billingModelSourceResponse: 'Facturar por el modelo de la respuesta del proveedor', + billingModelSourceHint: 'Controla qué nombre de modelo se usa para buscar el precio', + selectedCount: '{count} seleccionados', + searchGroups: 'Buscar grupos...', + noGroupsMatch: 'Ningún grupo coincide con tu búsqueda', + restrictModels: 'Restringir los modelos', + restrictModelsHint: 'Si está activado, solo se permiten los modelos de la lista de precios. El resto se rechaza.', + defaultPerRequestPrice: 'Precio por petición por defecto (se usa cuando ningún tramo coincide)', + defaultImagePrice: 'Precio de imagen por defecto (se usa cuando ningún tramo coincide)', + defaultVideoPrice: 'Precio de vídeo por segundo por defecto (se usa cuando ningún tramo coincide)', + platformConfig: 'Configuración de la plataforma', + webSearchEmulation: 'Emulación de búsqueda web', + webSearchEmulationHint: '⚠️ Si se activa, todas las cuentas de los grupos Anthropic de este canal interceptarán las peticiones web_search. Úsalo con cuidado.', + webSearchEmulationGlobalDisabled: 'Activa primero el interruptor global en Ajustes → Pasarela → Emulación de búsqueda web', + codexImageGenerationBridge: 'Puente de generación de imágenes de Codex', + codexImageGenerationBridgeHint: 'Si se activa, solo las peticiones de texto de Codex /responses que no sean Responses Lite en grupos de OpenAI reciben la herramienta alojada image_generation. El puente no inyecta herramientas en Responses Lite; el tratamiento local de image_gen sigue la política del cliente y de la cuenta. Déjalo desactivado salvo que las cuentas enrutadas admitan generación de imágenes.', + bedrockCCCompat: 'Compatibilidad CC de Bedrock', + bedrockCCCompatHint: '⚠️ Si se activa, las peticiones a cuentas de Bedrock de este canal se transformarán para ser compatibles con Claude Code (conversión del tipo de razonamiento, saneado de los ID de tool_use).', + basicSettings: 'Ajustes básicos', + addPlatform: 'Añadir una plataforma', + noPlatforms: 'Pulsa «Añadir una plataforma» para empezar a configurar el canal', + mappingCount: 'correspondencias', + pricingEntry: 'Entrada de precio', + noModels: 'No se han añadido modelos', + applyPricingToAccountStats: 'Aplicar los precios a las estadísticas de la cuenta', + applyPricingToAccountStatsDesc: 'Si se activa, las peticiones que no coincidan con reglas personalizadas usarán el precio estándar del modelo para calcular las estadísticas de la cuenta', + accountStatsPricingRules: 'Reglas de precio personalizadas para las estadísticas de cuenta', + addRule: 'Añadir una regla', + noRulesConfigured: 'No hay reglas personalizadas configuradas. Se usarán los precios de modelo del canal de arriba.', + ruleName: 'Nombre de la regla (opcional)', + ruleGroups: 'Grupos', + ruleAccounts: 'Cuentas', + searchAccountPlaceholder: 'Buscar cuentas...', + ruleAccountsHint: 'Déjalo vacío para que coincidan todas las cuentas', + ruleModelPricing: 'Precios por modelo', + noGroupsInChannel: 'No hay grupos seleccionados en las pestañas de plataforma de arriba', + unnamed: 'Sin nombre', + syncLatestModels: 'Sincronizar los últimos modelos', + syncingModels: 'Sincronizando...', + syncModelsSuccess: 'Se sincronizaron {count} modelo(s) nuevo(s)', + syncModelsAlreadyUpToDate: 'Los modelos ya están al día', + syncModelsError: 'No se pudieron sincronizar los modelos' + } + }, + + riskControl: { + title: 'Control de riesgos', + description: 'Configura la moderación de contenido y revisa los registros de auditoría', + loadFailed: 'No se pudo cargar el control de riesgos', + saveFailed: 'No se pudo guardar la configuración de moderación de contenido', + logsFailed: 'No se pudieron cargar los registros de auditoría', + saved: 'Configuración de moderación de contenido guardada', + refresh: 'Actualizar', + config: 'Configuración de moderación de contenido', + configHint: 'Usa OpenAI Moderations para puntuar el contenido de las peticiones y actuar según el modo cuando se supera el umbral.', + openSettings: 'Ajustes de moderación', + settingsTitle: 'Ajustes de moderación de contenido', + refreshStatus: 'Actualizar el estado', + records: 'Registros de auditoría', + recordsHint: 'Muestra coincidencias, bloqueos, errores y registros muestreados.', + saveConfig: 'Guardar la configuración de moderación', + statusFailed: 'No se pudo cargar el estado de ejecución', + enabled: 'Activar la moderación de contenido', + enabledHint: 'Si está desactivada, las peticiones de la pasarela no se moderan aunque el menú esté activado.', + mode: 'Modo global', + modePreBlock: 'Bloqueo previo', + modePreBlockDesc: 'Revisa de forma síncrona la última entrada del usuario antes de cada petición y rechaza las coincidencias de inmediato.', + modeObserve: 'Solo observar', + modeObserveDesc: 'Las peticiones pasan mientras la última entrada del usuario se encola para revisión asíncrona; las coincidencias se registran, se notifican y se cuentan.', + modeOff: 'Desactivado', + modeOffDesc: 'La moderación de contenido está desactivada y no se escriben registros de auditoría.', + baseUrl: 'URL base de OpenAI', + model: 'Modelo', + apiKey: 'Clave API de OpenAI', + apiKeys: 'Claves API de OpenAI', + apiKeyCount: '{count} claves', + apiKeyPlaceholder: 'Introduce la clave API', + apiKeysPlaceholder: 'Añade claves API, una por línea. Se añadirán al guardar.', + apiKeysPlaceholderReplace: 'Reemplaza las claves API, una por línea. Las guardadas se sustituirán al guardar.', + apiKeysPlaceholderKeep: 'Añade claves API, una por línea. Se añadirán al guardar.', + apiKeysHint: 'Ahora mismo hay {count} claves guardadas. Este campo solo añade claves; al guardar se suman y se eliminan los duplicados.', + apiKeysWriteMode: 'Modo de escritura', + apiKeysModeAppend: 'Añadir', + apiKeysModeReplace: 'Reemplazar', + apiKeysModeAppendHint: 'Por defecto: al guardar se añaden las claves introducidas y se conservan las guardadas.', + apiKeysModeReplaceHint: 'Modo reemplazar: al guardar se sustituyen todas las claves guardadas por las introducidas.', + apiKeysReplaceWarning: 'Modo reemplazar', + apiKeysReplaceNoInput: 'El modo reemplazar necesita al menos 1 clave API', + apiKeyPlaceholderKeep: 'Déjalo vacío para conservar la clave actual', + apiKeyWillClear: 'La clave configurada se borrará al guardar', + apiKeyConfigured: 'Configurada', + apiKeyTemporary: 'Pendiente', + apiKeyPendingDelete: 'Pendiente de eliminar', + apiKeyPendingDeleteCount: '{count} claves pendientes de eliminar', + deleteApiKey: 'Eliminar esta clave', + undoDeleteApiKey: 'Deshacer la eliminación', + inputApiKeyCount: '{count} claves introducidas', + storedApiKeyCount: '{count} claves guardadas', + testInputApiKeys: 'Probar las claves introducidas', + testStoredApiKeys: 'Probar las claves guardadas', + testContentWithStoredApiKey: 'Probar contenido con una clave guardada', + testingApiKeys: 'Probando', + apiKeyTestNoInput: 'Introduce primero claves API de OpenAI para probarlas', + apiKeyTestDone: 'Prueba de claves completada para {count} claves', + apiKeyTestFailed: 'No se pudieron probar las claves API de OpenAI', + apiKeyHealth: 'Disponibilidad de las claves', + apiKeyFreezeRule: 'Un 400 no congela; 401/403 congelan 10 minutos; 429/529 congelan 1 minuto; el resto de errores HTTP congelan 10 segundos.', + apiKeyRows: '{count} claves', + apiKeyRowsCollapsed: '{count} claves ocultas', + apiKeyRowsExpanded: 'Mostrando las {count} claves', + expandApiKeyRows: 'Expandir', + collapseApiKeyRows: 'Contraer', + apiKeyHealthEmpty: 'Todavía no hay estado de las claves', + apiKeyHealthEmptyHint: 'Guarda claves o prueba las introducidas para ver su disponibilidad.', + apiKeyStatusOk: 'Disponible', + apiKeyStatusError: 'Error', + apiKeyStatusFrozen: 'Congelada', + apiKeyStatusUnknown: 'Sin probar', + apiKeyFailureCount: '{count} fallos', + apiKeyLatency: '{ms} ms', + apiKeyHTTPStatus: 'HTTP {status}', + apiKeyFrozenUntil: 'Congelada hasta las {time}', + apiKeyLastChecked: 'Comprobada a las {time}', + apiKeyNotTested: 'Sin probar', + auditTestInput: 'Entrada de prueba de auditoría', + auditTestInputHint: 'Introduce un prompt y sube o pega imágenes; las imágenes se envían en base64 y no se guardan.', + auditTestPromptPlaceholder: 'Introduce un prompt de usuario para probar; déjalo vacío para probar solo la disponibilidad de las claves.', + auditTestImages: 'Imágenes de prueba', + auditTestImagesHint: 'Sube, arrastra o pega imágenes. Hasta 1 imagen de 8 MB como máximo.', + addAuditTestImage: 'Añadir una imagen', + clearAuditTest: 'Limpiar la prueba', + auditTestImageLimit: 'Puedes añadir hasta {count} imágenes de prueba', + auditTestImageTooLarge: 'Cada imagen de prueba debe ocupar 8 MB o menos', + auditTestImageReadFailed: 'No se pudo leer la imagen de prueba', + auditTestResult: 'Resultado de la prueba de auditoría', + auditTestHighest: 'Categoría principal {category}, puntuación {score}', + auditTestComposite: 'Puntuación combinada', + auditTestFlagged: 'Umbral superado', + auditTestPassed: 'Aprobado', + notConfigured: 'Sin configurar', + clearApiKey: 'Borrar la clave guardada', + keepApiKey: 'Conservar la clave guardada', + timeoutMs: 'Tiempo de espera HTTP (ms)', + retryCount: 'Número de reintentos', + sampleRate: 'Tasa de muestreo', + proxy: 'Servidor proxy', + proxyHint: 'Envía las peticiones de moderación por el proxy seleccionado (Gestión de IP - Servidores proxy), útil cuando OpenAI no admite la IP de salida. Por defecto, conexión directa.', + recordNonHits: 'Registrar las que no coinciden', + recordNonHitsHint: 'Si se activa, los resúmenes muestreados de peticiones sin coincidencia se censuran antes de guardarse.', + preHashCheck: 'Activar la comprobación previa por hash', + preHashCheckHint: 'Los hashes de coincidencias asíncronas se bloquean antes de la moderación; esto no envía correo ni incrementa los contadores de expulsión.', + flaggedHashCount: 'Tamaño actual de la colección de hashes: {count}', + flaggedHashHint: 'Los hashes se guardan de forma permanente en Redis; pega un hash completo de 64 caracteres para quitar un bloqueo erróneo, o borra todos los hashes guardados.', + flaggedHashPlaceholder: 'Pega el hash de entrada completo de 64 caracteres', + deleteFlaggedHash: 'Eliminar el hash', + clearFlaggedHashes: 'Borrar todos', + clearFlaggedHashesConfirm: '¿Borrar todos los hashes de entradas de riesgo? Esto no elimina los registros de auditoría, pero quita todos los bloqueos por hash del histórico.', + flaggedHashDeleted: 'Hash de riesgo eliminado', + flaggedHashNotFound: 'No se encontró el hash de riesgo', + flaggedHashDeleteFailed: 'No se pudo eliminar el hash de riesgo', + flaggedHashesCleared: 'Se borraron {count} hashes de riesgo', + flaggedHashesClearFailed: 'No se pudieron borrar los hashes de riesgo', + workerCount: 'Número de procesos', + queueSize: 'Tamaño de la cola asíncrona', + blockStatus: 'Código HTTP al bloquear', + blockMessage: 'Mensaje de bloqueo personalizado', + defaultBlockMessage: 'La auditoría de contenido detectó una regla de riesgo. Ajusta tu entrada e inténtalo de nuevo.', + emailOnHit: 'Enviar correo al coincidir', + emailOnHitHint: 'Si se activa, envía un correo de control de riesgos en cada coincidencia; los avisos de expulsión automática se envían siempre.', + autoBan: 'Expulsar al usuario automáticamente', + autoBanHint: 'Desactiva al usuario, invalida la caché de autenticación y envía un aviso de expulsión cuando se alcanza el umbral de coincidencias.', + cyberPolicyExcludeBan: 'Excluir del recuento de expulsión las coincidencias de política cibernética', + cyberPolicyExcludeBanHint: 'Si se activa, las coincidencias de cyber_policy dejan de contar como infracciones para la expulsión automática: no se valora la expulsión en la propia coincidencia y las filas del histórico se excluyen del recuento móvil. Los registros y los correos de aviso no se ven afectados.', + violationNotCounted: 'No cuenta', + banThreshold: 'Umbral de expulsión', + violationWindowHours: 'Ventana de recuento (horas)', + hitRetentionDays: 'Retención de registros con coincidencia (días)', + nonHitRetentionDays: 'Retención de registros sin coincidencia (días, máximo 3)', + violationCount: '{count} coincidencias', + emailSent: 'Correo enviado', + emailNotSent: 'Sin correo', + autoBanned: 'Expulsado', + unbanUser: 'Readmitir', + unbanSuccess: 'El usuario ha sido readmitido', + unbanFailed: 'No se pudo readmitir al usuario', + inputDetailTitle: 'Detalle del resumen de entrada', + inputDetailContent: 'Contenido completo', + matchedKeyword: 'Palabra clave coincidente', + queueDelay: 'En cola {ms} ms', + allGroups: 'Todos los grupos', + allGroupsHint: 'Auditando todos los grupos', + selectedGroupsHint: 'Auditando los grupos seleccionados', + groupScope: 'Grupos auditados', + groupScopeHint: 'Actívalo para todos los grupos, o desactívalo para elegir grupos concretos.', + selectedGroups: 'Grupos seleccionados', + searchGroups: 'Buscar por nombre de grupo o plataforma', + noGroups: 'No hay grupos disponibles', + modelFilter: 'Alcance por modelo', + modelFilterHint: 'Modera según el nombre de modelo que pide el cliente; las correspondencias de modelo del canal no cambian esta comparación.', + modelFilterAll: 'Todos los modelos', + modelFilterAllDesc: 'Todas las peticiones de modelo pasan por la moderación de contenido.', + modelFilterInclude: 'Solo los seleccionados', + modelFilterIncludeDesc: 'Solo los modelos de la lista pasan por la moderación de contenido.', + modelFilterExclude: 'Excluir los seleccionados', + modelFilterExcludeDesc: 'Los modelos de la lista se saltan la moderación; el resto se modera.', + modelFilterModels: 'Lista de modelos', + modelFilterModelCount: '{count} modelos configurados', + modelFilterModelsRequired: 'Este alcance por modelo necesita al menos 1 modelo', + modelFilterAllSummary: 'Se aplica a todos los modelos', + modelFilterIncludeSummary: 'Se aplica a {count} modelos', + modelFilterExcludeSummary: 'Excluye {count} modelos', + emptyLogs: 'No hay registros de auditoría', + preBlockSyncStatus: 'Estado del bloqueo previo síncrono', + preBlockSyncHint: 'Contadores en vivo de la ruta de moderación síncrona, sin contar las tareas de registro asíncronas.', + preBlockActive: 'Procesando en síncrono', + preBlockActiveHint: 'Comprobando ahora mismo', + preBlockChecked: 'Comprobadas', + preBlockCheckedHint: 'Entraron en la ruta de bloqueo previo', + preBlockAllowed: 'Permitidas', + preBlockAllowedHint: 'No se activó ningún bloqueo', + preBlockBlocked: 'Bloqueadas', + preBlockBlockedHint: 'Rechazadas tras una coincidencia', + preBlockErrors: 'Errores de auditoría', + preBlockErrorsHint: 'Falló o no había ninguna clave utilizable', + preBlockAvgLatency: 'Latencia media', + preBlockAvgLatencyHint: 'Media de la ruta síncrona', + preBlockAPIKeyLoad: 'Carga de las claves de auditoría', + preBlockAPIKeyLoadHint: 'Las comprobaciones síncronas de bloqueo previo rotan directamente entre las claves de auditoría utilizables.', + preBlockAPIKeyLoadSummary: 'Síncronas activas {active} / claves utilizables {available}, {total} en total, procesos: {workerActive} / {workerTotal}', + preBlockAPIKeyTotals: 'Total {total}, correctas {success}, errores {errors}', + preBlockAPIKeyLoadEmpty: 'Todavía no hay datos de carga de las claves de auditoría', + preBlockKeyActiveShort: 'Activas', + preBlockKeyTotalShort: 'Total', + preBlockKeyAvgShort: 'Media', + preBlockKeyLastShort: 'Última', + workerStatus: 'Ejecución de los procesos', + workerStatusHint: 'Estado de la cola y del grupo de procesos para las tareas de auditoría asíncrona y de registro del bloqueo previo, sin contar las comprobaciones síncronas.', + workerPool: 'Grupo de procesos', + workerPoolMeta: '{active} procesando, {idle} libres y listos, {total} en total', + queueUsage: 'Uso de la cola', + activeWorkers: 'Procesando', + idleWorkers: 'Libres y listos', + workerActive: 'Procesando una tarea de auditoría asíncrona o de registro', + workerIdle: 'Iniciado, libre y listo', + workerDisabled: 'El control de riesgos o la auditoría de contenido están desactivados', + processed: 'Procesadas', + droppedErrors: 'Descartadas / Errores', + autoRefresh: 'Actualizar automáticamente cada 15 s', + lastCleanup: 'Última limpieza: {time}', + cleanupStats: 'La última limpieza eliminó {hit} con coincidencia y {nonHit} sin coincidencia', + riskSwitchOff: 'Interruptor del sistema desactivado', + riskThresholds: 'Umbrales de riesgo', + riskThresholdsHint: 'Ajusta los umbrales de coincidencia por categoría de OpenAI Moderations. Las puntuaciones iguales o superiores al umbral cuentan como coincidencia.', + riskThresholdDefault: 'Por defecto {value}', + riskThresholdReset: 'Restaurar los valores por defecto', + riskThresholdPercent: 'Porcentaje del umbral', + tabs: { + basic: 'Básico', + scope: 'Alcance', + runtime: 'Ejecución', + response: 'Aviso de coincidencia', + riskThresholds: 'Umbrales de riesgo', + keywords: 'Bloqueo por palabras', + retention: 'Retención', + }, + blockedKeywords: 'Palabras bloqueadas', + blockedKeywordsPlaceholder: 'Una palabra por línea, por ejemplo:\npalabramala1\npalabramala2', + blockedKeywordsDescription: 'La comparación no distingue mayúsculas. Que se llame o no a la API de moderación tras una coincidencia depende de la estrategia de abajo.', + blockedKeywordsPreBlockHint: 'El bloqueo por palabras solo funciona en el modo «Bloqueo previo».', + blockedKeywordsModeWarning: 'El modo actual es «{mode}». El bloqueo por palabras no se ejecutará hasta que cambies al modo «Bloqueo previo».', + blockedKeywordCount: '{count} palabras configuradas', + blockedKeywordsLimit: 'Hasta {max} palabras, cada una de 200 caracteres como máximo. Los duplicados se eliminan automáticamente.', + keywordBlockingMode: 'Estrategia de moderación', + keywordModeKeywordAndApi: 'Palabras + API', + keywordModeKeywordAndApiDesc: 'Bloquea si coincide una palabra; si no, sigue con la API de moderación del proveedor.', + keywordModeKeywordOnly: 'Solo palabras', + keywordModeKeywordOnlyDesc: 'Decide solo con las palabras; lo que no coincida se permite sin llamar a la API, lo que ahorra coste.', + keywordModeKeywordOnlyNotice: 'Estrategia de solo palabras: las peticiones que no coincidan con ninguna palabra se permiten sin llamar a la API de moderación.', + keywordModeApiOnly: 'Solo API', + keywordModeApiOnlyDesc: 'Usa únicamente la API de moderación del proveedor; la lista de palabras configurada aquí no se consulta.', + keywordModeApiOnlyNotice: 'Estrategia de solo API: la lista de palabras no se consulta; todas las peticiones pasan por la API de moderación.', + overview: { + status: 'Estado', + enabled: 'Activado', + disabled: 'Desactivado', + apiKey: 'Clave API', + groupScope: 'Alcance', + logs: 'Registros de auditoría', + currentFilter: 'Filtro actual', + }, + filters: { + search: 'Buscar usuario/clave/resumen', + from: 'Desde', + to: 'Hasta', + allGroups: 'Todos los grupos', + allEndpoints: 'Todos los endpoints', + }, + table: { + time: 'Hora', + group: 'Grupo', + user: 'Usuario', + apiKey: 'Clave API', + endpoint: 'Endpoint', + result: 'Resultado', + highest: 'Máximo', + actionMeta: 'Acción', + latency: 'Latencia', + input: 'Resumen de la entrada', + }, + result: { + all: 'Todos los resultados', + hit: 'Coincidencia', + blocked: 'Bloqueada', + pass: 'Aprobada', + error: 'Error', + }, + action: { + block: 'Bloqueada', + keywordBlock: 'Bloqueada por palabra', + cyberPolicy: 'Política cibernética', + error: 'Error', + }, + }, + + // Channel Monitor + channelMonitor: { + title: 'Monitor de canales', + description: 'Vigila la disponibilidad, la latencia y el estado de los canales', + searchPlaceholder: 'Buscar por nombre del monitor...', + allProviders: 'Todos los proveedores', + allStatus: 'Todos los estados', + enabledFilter: 'Activado', + onlyEnabled: 'Solo los activados', + onlyDisabled: 'Solo los desactivados', + createButton: 'Crear un monitor', + createTitle: 'Crear un monitor de canal', + editTitle: 'Editar el monitor de canal', + runNow: 'Ejecutar ahora', + runSuccess: 'Comprobación completada', + runFailed: 'La comprobación falló', + duplicate: 'Duplicar', + duplicating: 'Duplicando', + duplicateSuccess: 'Monitor duplicado como «{name}» y desactivado. Revisa su configuración antes de activarlo.', + duplicateFailed: 'No se pudo duplicar el monitor', + duplicateKeyUnavailable: 'La clave API no se puede descifrar. Vuelve a introducirla antes de duplicar este monitor.', + apiKeyDecryptFailed: 'Falló el descifrado de la clave API. Edita este monitor con una clave nueva.', + createSuccess: 'Monitor creado', + updateSuccess: 'Monitor actualizado', + deleteSuccess: 'Monitor eliminado', + loadError: 'No se pudieron cargar los monitores', + deleteConfirm: '¿Seguro que quieres eliminar el monitor «{name}»? Esta acción no se puede deshacer.', + nameRequired: 'Introduce un nombre para el monitor', + primaryModelRequired: 'Introduce un modelo principal', + columns: { + name: 'Nombre', + provider: 'Proveedor', + primaryModel: 'Modelo principal', + availability7d: 'Disponibilidad 7 d', + latency: 'Latencia (ms)', + enabled: 'Activado', + actions: 'Acciones' + }, + form: { + name: 'Nombre', + namePlaceholder: 'Introduce el nombre del monitor', + provider: 'Plataforma', + apiMode: 'Protocolo de OpenAI', + apiModeChatCompletions: 'Compatible con OpenAI', + apiModeChatCompletionsHint: 'Usa /v1/chat/completions con messages; funciona con la mayoría de proveedores compatibles.', + apiModeResponses: 'API Responses', + apiModeResponsesHint: 'Usa /v1/responses con instructions + input por defecto; ideal para rutas de autocomprobación y Codex.', + endpoint: 'Endpoint', + endpointPlaceholder: 'https://api.ejemplo.com', + useCurrentDomain: 'Usar el servicio actual', + apiKey: 'Clave API', + apiKeyPlaceholder: 'Introduce la clave API', + apiKeyEditPlaceholder: 'Déjalo vacío para conservar la clave actual', + useMyKey: 'Usar mi clave', + selectKeyTitle: 'Selecciona mi clave API', + selectKeyHint: 'Solo se listan tus claves activas y no caducadas.', + noActiveKey: 'No hay claves API activas disponibles', + primaryModel: 'Modelo principal', + primaryModelPlaceholder: 'gpt-4o-mini', + extraModels: 'Modelos adicionales', + extraModelsPlaceholder: 'Pulsa Intro para añadir un modelo adicional', + groupName: 'Nombre del grupo', + groupNamePlaceholder: 'Opcional, sirve para agrupar filas en la vista de usuario', + intervalSeconds: 'Intervalo (segundos)', + intervalSecondsHint: 'Rango: de 15 a 3600 segundos', + jitterSeconds: 'Variación aleatoria (± segundos)', + jitterSecondsHint: 'Cada comprobación se lanza en el intervalo ± un desfase aleatorio dentro de este valor; 0 significa intervalo fijo. El intervalo menos la variación debe ser ≥ 15 s', + enabled: 'Activar el monitor', + kindRequired: 'Selecciona un proveedor' + }, + runResultTitle: 'Resultado de la comprobación', + noMonitorsYet: 'Todavía no hay monitores', + createFirstMonitor: 'Crea tu primer monitor para vigilar la disponibilidad de los canales', + advanced: { + section: 'Avanzado (opcional)', + sectionHint: 'Personaliza las cabeceras y el cuerpo de la petición para saltarte la detección de cliente del proveedor (p. ej. «solo se permiten clientes de Claude Code»).', + headers: 'Cabeceras personalizadas', + headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219', + headerNamePlaceholder: 'Nombre de la cabecera', + headerValuePlaceholder: 'Valor', + headerAddRow: 'Añadir una cabecera', + headerNameInvalid: 'El nombre de la cabecera no puede llevar espacios ni dos puntos: {name}', + headersHint: 'Se combinan sobre los valores por defecto del adaptador (gana el tuyo). Las cabeceras salto a salto (Host / Content-Length / ...) se ignoran.', + headersParseError: 'No se puede interpretar la línea: {line}', + bodyMode: 'Tratamiento del cuerpo', + bodyModeOff: 'Por defecto', + bodyModeMerge: 'Combinar', + bodyModeReplace: 'Reemplazar', + bodyModeHintOff: 'Usa el cuerpo por defecto del adaptador (incluye la validación del desafío).', + bodyModeHintMerge: 'Combina de forma superficial con el cuerpo por defecto; ganan tus campos, pero model / messages / contents están protegidos (usa Reemplazar para cambiarlos).', + bodyModeHintReplace: 'Usa el JSON de abajo como cuerpo completo. Se omite la validación del desafío; un HTTP 2xx con texto de respuesta no vacío se considera operativo.', + bodyJson: 'JSON del cuerpo', + bodyJsonFormat: 'Formatear', + bodyJsonHint: 'Se interpreta al salir del campo. Vacío significa que no se sobrescribe.', + bodyJsonError: 'Falló la interpretación del JSON', + bodyJsonObjectError: 'El cuerpo debe ser un objeto JSON (no arrays ni valores simples)' + }, + templateField: { + label: 'Plantilla de petición', + none: 'Sin plantilla', + placeholder: 'Elige una plantilla (filtradas por el proveedor actual)', + applyHint: 'Elegir una plantilla copia sus cabeceras y su cuerpo a este monitor (como una foto fija). Los cambios posteriores en la plantilla no se sincronizan solos.' + }, + template: { + manageButton: 'Plantillas', + managerTitle: 'Gestor de plantillas de petición', + createButton: 'Plantilla nueva', + emptyState: 'Todavía no hay plantillas para este proveedor', + missingName: 'El nombre de la plantilla es obligatorio', + createSuccess: 'Plantilla creada', + updateSuccess: 'Plantilla actualizada', + deleteSuccess: 'Plantilla eliminada', + applyButton: 'Aplicar a los monitores', + applyTooltip: 'Sobrescribe los campos copiados en los monitores asociados', + applyTitle: 'Aplicar la plantilla', + applyConfirm: 'Aplicar', + applyConfirmMessage: '¿Sobrescribir {n} monitor(es) asociado(s) con la configuración actual de «{name}»? Se descartará cualquier personalización local de esos monitores.', + applySuccess: 'Aplicada a {n} monitor(es)', + applyPickerTitle: 'Aplicar la plantilla «{name}»', + applyPickerHint: 'Selecciona qué monitores sobrescribir (todos seleccionados por defecto). Se descartará cualquier personalización local.', + applyPickerEmpty: 'Ahora mismo no hay monitores asociados a esta plantilla', + applyPickerConfirm: 'Aplicar a {n} monitor(es)', + selectNone: 'No seleccionar ninguno', + selectedCount: 'Seleccionados {n} / {total}', + deleteConfirm: '¿Eliminar la plantilla «{name}»? Se desasociarán {n} monitor(es), pero conservarán su configuración actual y seguirán funcionando.', + associatedCount: '{n} monitor(es) asociado(s)', + headersSummary: '{n} cabecera(s) personalizada(s)', + form: { + name: 'Nombre de la plantilla', + namePlaceholder: 'p. ej. Imitación de Claude Code', + description: 'Descripción', + descriptionPlaceholder: 'Opcional: para qué sirve esta plantilla, fecha de captura, etc.' + } + } + }, + + // Subscriptions + subscriptions: { + title: 'Gestión de suscripciones', + description: 'Gestiona las suscripciones de los usuarios y sus límites de cuota', + assignSubscription: 'Asignar una suscripción', + adjustSubscription: 'Ajustar la suscripción', + revokeSubscription: 'Revocar la suscripción', + restoreSubscription: 'Restaurar la suscripción', + allStatus: 'Todos los estados', + allGroups: 'Todos los grupos', + allPlatforms: 'Todas las plataformas', + daily: 'Diario', + weekly: 'Semanal', + monthly: 'Mensual', + noLimits: 'No hay límites configurados', + unlimited: 'Ilimitado', + resetNow: 'Se restablece en breve', + windowNotActive: 'Ventana no activa', + resetInMinutes: 'Se restablece en {minutes} min', + resetInHoursMinutes: 'Se restablece en {hours} h {minutes} min', + resetInDaysHours: 'Se restablece en {days} d {hours} h', + quotaEndsInMinutes: 'La cuota termina en {minutes} min', + quotaEndsInHoursMinutes: 'La cuota termina en {hours} h {minutes} min', + quotaEndsInDaysHours: 'La cuota termina en {days} d {hours} h', + daysRemaining: 'Quedan {days} días', + hoursMinutesRemaining: 'Quedan {hours} h {minutes} min', + minutesRemaining: 'Quedan {minutes} min', + remainingDays: 'Días restantes', + noExpiration: 'Sin caducidad', + status: { + active: 'Activa', + expired: 'Caducada', + revoked: 'Revocada', + suspended: 'Suspendida' + }, + columns: { + user: 'Usuario', + group: 'Grupo', + usage: 'Consumo', + expires: 'Caduca', + status: 'Estado', + actions: 'Acciones' + }, + form: { + user: 'Usuario', + group: 'Grupo de suscripción', + validityDays: 'Validez (días)', + adjustDays: 'Ajustar en (días)' + }, + selectUser: 'Selecciona un usuario', + selectGroup: 'Selecciona un grupo de suscripción', + groupHint: 'Solo se muestran los grupos con facturación de tipo suscripción', + validityHint: 'Número de días que será válida la suscripción', + adjustingFor: 'Ajustando la suscripción de', + currentExpiration: 'Caducidad actual', + adjustDaysPlaceholder: 'Positivo para ampliar, negativo para acortar', + adjustHint: 'Introduce un número positivo para ampliar, negativo para acortar (los días restantes deben ser > 0)', + assign: 'Asignar', + assigning: 'Asignando...', + adjust: 'Ajustar', + adjusting: 'Ajustando...', + revoke: 'Revocar', + restore: 'Restaurar', + resetQuota: 'Restablecer la cuota', + resetQuotaTitle: 'Restablecer la cuota de consumo', + resetQuotaConfirm: '¿Restablecer la cuota de consumo diaria, semanal y mensual de «{user}»? El consumo se pondrá a cero y las ventanas empezarán de nuevo desde hoy.', + quotaResetSuccess: 'Cuota restablecida correctamente', + failedToResetQuota: 'No se pudo restablecer la cuota', + noSubscriptionsYet: 'Todavía no hay suscripciones', + assignFirstSubscription: 'Asigna una suscripción para empezar.', + subscriptionAssigned: 'Suscripción asignada correctamente', + subscriptionAdjusted: 'Suscripción ajustada correctamente', + subscriptionRevoked: 'Suscripción revocada correctamente', + subscriptionRestored: 'Suscripción restaurada correctamente', + failedToLoad: 'No se pudieron cargar las suscripciones', + failedToAssign: 'No se pudo asignar la suscripción', + failedToAdjust: 'No se pudo ajustar la suscripción', + failedToRevoke: 'No se pudo revocar la suscripción', + failedToRestore: 'No se pudo restaurar la suscripción', + adjustWouldExpire: 'Los días restantes tras el ajuste deben ser mayores que 0', + adjustOutOfRange: 'Los días de ajuste deben estar entre -36500 y 36500', + pleaseSelectUser: 'Selecciona un usuario', + pleaseSelectGroup: 'Selecciona un grupo', + validityDaysRequired: 'Introduce un número de días válido (al menos 1)', + revokeConfirm: + '¿Seguro que quieres revocar la suscripción de «{user}»? Podrás restaurarla más adelante desde la lista de revocadas.', + restoreConfirm: + '¿Restaurar la suscripción de «{user}»? Si la suscripción original ya caducó, se restaurará como caducada.', + guide: { + title: 'Guía de gestión de suscripciones', + subtitle: 'El modo suscripción te permite asignar cuotas de consumo por tiempo a tus usuarios, con límites diarios, semanales y mensuales. Sigue estos pasos para empezar.', + showGuide: 'Guía de uso', + step1: { + title: 'Crear un grupo de suscripción', + line1: 'Ve a la página «Gestión de grupos» y pulsa «Crear grupo»', + line2: 'Pon el tipo de facturación en «Suscripción» y configura los límites de cuota diarios, semanales y mensuales', + line3: 'Guarda el grupo y asegúrate de que su estado sea «Activo»', + link: 'Ir a Gestión de grupos' + }, + step2: { + title: 'Asignar la suscripción a un usuario', + line1: 'Pulsa el botón «Asignar una suscripción» de la esquina superior derecha', + line2: 'Busca al usuario por su correo y selecciónalo', + line3: 'Elige un grupo de suscripción, define los días de validez y pulsa «Asignar»' + }, + step3: { + title: 'Gestionar las suscripciones existentes' + }, + actions: { + adjust: 'Ajustar', + adjustDesc: 'Amplía o acorta el periodo de validez de la suscripción', + resetQuota: 'Restablecer la cuota', + resetQuotaDesc: 'Pone a cero el consumo diario, semanal y mensual', + revoke: 'Revocar', + revokeDesc: 'Termina la suscripción de inmediato (se puede restaurar desde la lista de revocadas)' + }, + tip: 'Consejo: en el desplegable de grupos solo aparecen los que tienen facturación de tipo «Suscripción» y estado «Activo». Si no ves ninguna opción, crea uno primero en Gestión de grupos.' + } + }, + + // Accounts +} diff --git a/frontend/src/i18n/locales/es/admin/index.ts b/frontend/src/i18n/locales/es/admin/index.ts new file mode 100644 index 00000000000..224ab94b397 --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/index.ts @@ -0,0 +1,19 @@ +import overview from './overview' +import channels from './channels' +import accounts from './accounts' +import resources from './resources' +import ops from './ops' +import settings from './settings' +import audit from './audit' +import promptAudit from './promptAudit' + +export default { + ...overview, + ...channels, + ...accounts, + ...resources, + ...ops, + ...settings, + ...audit, + ...promptAudit, +} diff --git a/frontend/src/i18n/locales/es/admin/ops.ts b/frontend/src/i18n/locales/es/admin/ops.ts new file mode 100644 index 00000000000..2d25af07871 --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/ops.ts @@ -0,0 +1,809 @@ +export default { + ops: { + title: 'Monitorización de operaciones', + description: 'Monitorización operativa y resolución de problemas', + // Dashboard + systemHealth: 'Salud del sistema', + overview: 'Resumen', + noSystemMetrics: 'Todavía no se han recogido métricas del sistema.', + collectedAt: 'Recogidas a las:', + window: 'ventana', + memory: 'Memoria', + db: 'BD', + goroutines: 'Goroutines', + jobs: 'Tareas', + jobsHelp: 'Pulsa «Detalles» para ver los latidos de las tareas y los errores recientes', + active: 'activas', + idle: 'libres', + waiting: 'esperando', + conns: 'conexiones', + queue: 'cola', + accountSwitches: 'Cambios de cuenta', + ok: 'ok', + lastRun: 'última_ejecución:', + lastSuccess: 'último_éxito:', + lastError: 'último_error:', + result: 'Resultado', + noData: 'Sin datos.', + loadingText: 'cargando', + ready: 'listo', + autoRefreshRemaining: 'Quedan {seconds} s', + systemLogs: { + title: 'Registros del sistema', + description: 'Los registros más recientes salen primero. Filtra, busca y limpia por condiciones.', + queue: 'Cola', + written: 'Escritos', + dropped: 'Descartados', + failed: 'Fallidos', + runtimeConfig: 'Configuración de registro en caliente (se aplica de inmediato)', + all: 'Todos', + level: 'Nivel', + stacktraceThreshold: 'Umbral de traza de pila', + samplingInitial: 'Muestreo inicial', + samplingThereafter: 'Muestreo posterior', + retentionDays: 'Días de retención', + caller: 'origen', + sampling: 'muestreo', + saveAndApply: 'Guardar y aplicar', + resetDefaults: 'Restaurar los valores por defecto', + latestWriteError: 'Último error de escritura:', + timeRange: 'Rango de tiempo', + startTime: 'Hora de inicio (opcional)', + endTime: 'Hora de fin (opcional)', + host: 'Servidor', + component: 'Componente', + componentPlaceholder: 'p. ej. http.access', + keyId: 'ID DE CLAVE', + platform: 'Plataforma', + model: 'Modelo', + keyword: 'Palabra clave', + keywordPlaceholder: 'mensaje/request_id', + search: 'Buscar', + cleanCurrentFilters: 'Limpiar con los filtros actuales', + refreshHealth: 'Actualizar el estado', + empty: 'No hay registros del sistema', + time: 'Hora', + logDetails: 'Detalles del registro', + loadFailed: 'No se pudieron cargar los registros del sistema', + runtimeConfigActive: 'La configuración de registro en caliente está activa', + runtimeConfigSaveFailed: 'No se pudo guardar la configuración de registro', + resetRuntimeConfigConfirm: '¿Volver a la configuración de arranque (env/yaml) y aplicarla de inmediato?', + runtimeConfigReset: 'Se volvió a la configuración de registro de arranque', + runtimeConfigResetFailed: 'No se pudo restablecer la configuración de registro', + cleanupConfirm: '¿Limpiar los registros del sistema que coinciden con los filtros actuales? No se puede deshacer.', + cleanupSuccess: 'Limpieza completada. Se eliminaron {count} entradas de registro.', + cleanupFilterRequired: 'La limpieza necesita al menos un filtro (hora de inicio/fin u otro campo)', + cleanupFailed: 'No se pudieron limpiar los registros del sistema' + }, + requestsTotal: 'Peticiones (total)', + slaScope: 'Alcance del SLA:', + tokens: 'Tokens', + tps: 'TPS:', + current: 'actual', + peak: 'máximo', + average: 'media', + totalRequests: 'Peticiones totales', + avgQps: 'QPS medio', + avgTps: 'TPS medio', + avgLatency: 'Duración media de la petición', + avgTtft: 'TTFT medio', + exceptions: 'Excepciones', + requestErrors: 'Errores de petición', + errorCount: 'Número de errores', + upstreamErrors: 'Errores del proveedor', + errorCountExcl429529: 'Número de errores (sin 429/529)', + sla: 'SLA (sin límites de negocio)', + businessLimited: 'limitado_por_negocio:', + errors: 'Errores', + errorRate: 'tasa_de_error:', + upstreamRate: 'tasa_del_proveedor:', + latencyDuration: 'Duración de la petición', + ttftLabel: 'TTFT (first_token_ms)', + p50: 'p50:', + p90: 'p90:', + p95: 'p95:', + p99: 'p99:', + avg: 'media:', + max: 'máx.:', + requests: 'Peticiones', + requestsTitle: 'Peticiones', + upstream: 'Proveedor', + client: 'Cliente', + system: 'Sistema', + other: 'Otros', + errorsSla: 'Errores (alcance del SLA)', + upstreamExcl429529: 'Proveedor (sin 429/529)', + failedToLoadData: 'No se pudieron cargar los datos de operaciones.', + failedToLoadOverview: 'No se pudo cargar el resumen', + failedToLoadThroughputTrend: 'No se pudo cargar la tendencia de rendimiento', + failedToLoadSwitchTrend: 'No se pudo cargar la tendencia de cambios medios de cuenta', + failedToLoadLatencyHistogram: 'No se pudo cargar el histograma de duración de las peticiones', + failedToLoadErrorTrend: 'No se pudo cargar la tendencia de errores', + failedToLoadErrorDistribution: 'No se pudo cargar el reparto de errores', + failedToLoadErrorDetail: 'No se pudo cargar el detalle del error', + retryFailed: 'El reintento falló', + tpsK: 'TPS (K)', + top: 'Máximo:', + throughputTrend: 'Tendencia de rendimiento', + switchRateTrend: 'Cambios medios de cuenta', + latencyHistogram: 'Histograma de duración de las peticiones', + errorTrend: 'Tendencia de errores', + errorDistribution: 'Reparto de errores', + switchRate: 'Cambios medios', + // Health Score & Diagnosis + health: 'Salud', + healthCondition: 'Estado de salud', + healthHelp: 'Puntuación general de salud del sistema basada en el SLA, la tasa de error y el uso de recursos', + healthyStatus: 'Saludable', + riskyStatus: 'En riesgo', + idleStatus: 'Inactivo', + timeRange: { + '5m': 'Últimos 5 minutos', + '30m': 'Últimos 30 minutos', + '1h': 'Última hora', + '1d': 'Último día', + '15d': 'Últimos 15 días', + '6h': 'Últimas 6 horas', + '24h': 'Últimas 24 horas', + '7d': 'Últimos 7 días', + '30d': 'Últimos 30 días', + custom: 'Rango personalizado' + }, + customTimeRange: { + startTime: 'Hora de inicio', + endTime: 'Hora de fin' + }, + openaiTokenStats: { + title: 'Estadísticas de peticiones de tokens de OpenAI', + viewModeTopN: 'Top N', + viewModePagination: 'Paginado', + prevPage: 'Anterior', + nextPage: 'Siguiente', + pageInfo: 'Página {page}/{total}', + totalModels: 'Modelos totales: {total}', + failedToLoad: 'No se pudieron cargar las estadísticas de tokens de OpenAI', + empty: 'No hay estadísticas de tokens de OpenAI con los filtros actuales', + table: { + model: 'Modelo', + requestCount: 'Peticiones', + avgTokensPerSec: 'Tokens/s medios', + avgFirstTokenMs: 'Latencia media del primer token (ms)', + totalOutputTokens: 'Tokens de salida totales', + avgDurationMs: 'Duración media (ms)', + requestsWithFirstToken: 'Peticiones con primer token' + } + }, + fullscreen: { + enter: 'Pantalla completa' + }, + diagnosis: { + title: 'Diagnóstico inteligente', + footer: 'Sugerencias de diagnóstico automáticas basadas en las métricas actuales', + idle: 'El sistema está inactivo en este momento', + idleImpact: 'No hay tráfico activo', + // Resource diagnostics + dbDown: 'Falló la conexión con la base de datos', + dbDownImpact: 'Todas las operaciones con la base de datos fallarán', + dbDownAction: 'Revisa el estado del servicio de base de datos, la conectividad de red y la configuración de conexión', + redisDown: 'Falló la conexión con Redis', + redisDownImpact: 'La caché queda degradada y el rendimiento puede bajar', + redisDownAction: 'Revisa el estado del servicio Redis y la conectividad de red', + cpuCritical: 'Uso de CPU críticamente alto ({usage}%)', + cpuCriticalImpact: 'El sistema responde más lento, puede afectar a todas las peticiones', + cpuCriticalAction: 'Revisa las tareas que consumen CPU, valora escalar u optimizar el código', + cpuHigh: 'Uso de CPU elevado ({usage}%)', + cpuHighImpact: 'La carga del sistema es alta, hay que estar atentos', + cpuHighAction: 'Vigila la tendencia de CPU y prepara un plan para escalar', + memoryCritical: 'Uso de memoria críticamente alto ({usage}%)', + memoryCriticalImpact: 'Puede provocar un OOM y comprometer la estabilidad del sistema', + memoryCriticalAction: 'Busca fugas de memoria, valora ampliar la memoria u optimizar su uso', + memoryHigh: 'Uso de memoria elevado ({usage}%)', + memoryHighImpact: 'La presión de memoria es alta, hay que estar atentos', + memoryHighAction: 'Vigila la tendencia de memoria y busca posibles fugas', + ttftHigh: 'El tiempo hasta el primer token es elevado ({ttft} ms)', + ttftHighImpact: 'La latencia que percibe el usuario ha aumentado', + ttftHighAction: 'Optimiza el flujo de procesamiento de peticiones y reduce el tiempo de preprocesado', + // Error rate diagnostics + upstreamCritical: 'Tasa de error del proveedor críticamente alta ({rate}%)', + upstreamCriticalImpact: 'Puede afectar a muchas peticiones de usuarios', + upstreamCriticalAction: 'Revisa el estado del servicio del proveedor y activa estrategias de respaldo', + upstreamHigh: 'Tasa de error del proveedor elevada ({rate}%)', + upstreamHighImpact: 'Conviene revisar el estado del servicio del proveedor', + upstreamHighAction: 'Contacta con el equipo del proveedor y prepara un plan alternativo', + errorHigh: 'Tasa de error demasiado alta ({rate}%)', + errorHighImpact: 'Muchas peticiones están fallando', + errorHighAction: 'Revisa los registros de error, identifica la causa raíz, hace falta una corrección urgente', + errorElevated: 'Tasa de error elevada ({rate}%)', + errorElevatedImpact: 'Conviene revisar los registros de error', + errorElevatedAction: 'Analiza los tipos y el reparto de los errores y crea un plan de corrección', + // SLA diagnostics + slaCritical: 'El SLA está críticamente por debajo del objetivo ({sla}%)', + slaCriticalImpact: 'La experiencia de usuario está gravemente degradada', + slaCriticalAction: 'Investiga con urgencia los errores y la latencia, valora limitar la tasa', + slaLow: 'El SLA está por debajo del objetivo ({sla}%)', + slaLowImpact: 'La calidad del servicio requiere atención', + slaLowAction: 'Analiza las causas de la caída del SLA y optimiza el rendimiento del sistema', + // Health score diagnostics + healthCritical: 'La puntuación general de salud es críticamente baja ({score})', + healthCriticalImpact: 'Puede haber varias métricas degradadas; investiga primero la tasa de error y la latencia', + healthCriticalAction: 'Revisión completa del sistema, dando prioridad a los problemas críticos', + healthLow: 'La puntuación general de salud es baja ({score})', + healthLowImpact: 'Puede indicar una inestabilidad leve; vigila el SLA y las tasas de error', + healthLowAction: 'Vigila la tendencia de las métricas para evitar que el problema crezca', + healthy: 'Todas las métricas del sistema son normales', + healthyImpact: 'El servicio funciona de forma estable' + }, + // Error Log + errorLog: { + timeId: 'Hora / ID', + commonErrors: { + contextDeadlineExceeded: 'context deadline exceeded', + connectionRefused: 'connection refused', + rateLimit: 'rate limit' + }, + time: 'Hora', + type: 'Tipo', + context: 'Contexto', + platform: 'Plataforma', + model: 'Modelo', + group: 'Grupo', + user: 'Usuario', + userId: 'ID del usuario', + apiKey: 'Clave API', + keyDeletedBadge: 'Clave eliminada', + account: 'Cuenta', + accountId: 'ID de la cuenta', + status: 'Estado', + message: 'Mensaje', + ip: 'IP', + latency: 'Duración de la petición', + action: 'Acción', + noErrors: 'No hay errores en esta ventana.', + grp: 'GRP:', + acc: 'CTA:', + details: 'Detalles', + phase: 'Fase', + id: 'ID:', + typeUpstream: 'Proveedor', + typeRequest: 'Petición', + typeAuth: 'Autenticación', + typeAccountAuth: 'Autenticación de la cuenta', + typeRouting: 'Enrutado', + typeInternal: 'Interno', + endpoint: 'Endpoint', + requestType: 'Tipo', + requestTypeSync: 'Síncrona', + requestTypeStream: 'Flujo', + requestTypeWs: 'WS' + }, + // Error Details Modal + errorDetails: { + upstreamErrors: 'Errores del proveedor', + requestErrors: 'Errores de petición', + unresolved: 'Sin resolver', + resolved: 'Resueltos', + viewErrors: 'Errores', + viewExcluded: 'Excluidos', + statusCodeOther: 'Otros', + owner: { + provider: 'Proveedor', + client: 'Cliente', + platform: 'Plataforma' + }, + phase: { + request: 'Petición', + auth: 'Autenticación', + account_auth: 'Autenticación de la cuenta', + routing: 'Enrutado', + upstream: 'Proveedor', + network: 'Red', + internal: 'Interno' + }, + total: 'Total:', + searchPlaceholder: 'Buscar request_id / client_request_id / mensaje', + }, + // Error Detail Modal + errorDetail: { + title: 'Detalle del error', + titleWithId: 'Error n.º {id}', + noErrorSelected: 'No hay ningún error seleccionado.', + resolution: 'Resuelto:', + failedToUpdateResolvedStatus: 'No se pudo actualizar el estado de resolución', + classificationKeys: { + phase: 'Fase', + owner: 'Responsable', + source: 'Origen', + resolvedAt: 'Resuelto el', + resolvedBy: 'Resuelto por' + }, + source: { + upstream_http: 'HTTP del proveedor' + }, + upstreamKeys: { + status: 'Estado', + message: 'Mensaje', + detail: 'Detalle', + upstreamErrors: 'Errores del proveedor' + }, + upstreamEvent: { + account: 'Cuenta', + status: 'Estado', + requestId: 'ID de la petición' + }, + responsePreview: { + expand: 'Respuesta (pulsa para expandir)', + collapse: 'Respuesta (pulsa para contraer)' + }, + loading: 'Cargando…', + requestId: 'ID de la petición', + time: 'Hora', + phase: 'Fase', + status: 'Estado', + message: 'Mensaje', + basicInfo: 'Información básica', + platform: 'Plataforma', + model: 'Modelo', + group: 'Grupo', + user: 'Usuario', + account: 'Cuenta', + latency: 'Duración de la petición', + businessLimited: 'Limitado por negocio', + requestPath: 'Ruta de la petición', + inboundEndpoint: 'Endpoint de entrada', + upstreamEndpoint: 'Endpoint del proveedor', + requestedModel: 'Modelo solicitado', + upstreamModel: 'Modelo del proveedor', + requestType: 'Tipo de petición', + requestTypeUnknown: 'Desconocido', + requestTypeSync: 'Síncrona', + requestTypeStream: 'Flujo', + requestTypeWs: 'WebSocket', + modelMapping: 'Correspondencia de modelos', + timings: 'Tiempos', + auth: 'Autenticación', + routing: 'Enrutado', + upstream: 'Proveedor', + response: 'Respuesta', + classification: 'Clasificación', + errorBody: 'Cuerpo del error', + trimmed: 'recortado', + markResolved: 'Marcar como resuelto', + markUnresolved: 'Marcar como sin resolver', + tabOverview: 'Resumen', + tabRequest: 'Petición', + tabResponse: 'Respuesta', + responseBody: 'Respuesta', + compareA: 'Comparar A', + compareB: 'Comparar B', + suggestion: 'Sugerencia', + suggestUpstream: 'Inestabilidad del proveedor: revisa el estado de la cuenta o valora cambiar de cuenta', + suggestRequest: 'Error en la petición del cliente: pide al cliente que corrija los parámetros', + suggestAuth: 'Falló la autenticación: verifica la clave API o las credenciales', + suggestPlatform: 'Error de la plataforma: investígalo y corrígelo con prioridad', + suggestGeneric: 'Consulta los detalles para tener más contexto', + apiKeyPrefix: 'Prefijo de la clave', + keyDeletedBadge: 'Clave eliminada' + }, + requestDetails: { + title: 'Detalles de las peticiones', + details: 'Detalles', + rangeLabel: 'Ventana: {range}', + rangeMinutes: '{n} minutos', + rangeHours: '{n} horas', + empty: 'No hay peticiones en esta ventana.', + emptyHint: 'Prueba con otro rango de tiempo o quita los filtros.', + failedToLoad: 'No se pudieron cargar los detalles de las peticiones', + requestIdCopied: 'ID de la petición copiado', + copyFailed: 'Falló la copia', + copy: 'Copiar', + viewError: 'Ver el error', + kind: { + success: 'CORRECTA', + error: 'ERROR' + }, + table: { + time: 'Hora', + kind: 'Tipo', + platform: 'Plataforma', + model: 'Modelo', + duration: 'Duración', + status: 'Estado', + requestId: 'ID de la petición', + actions: 'Acciones' + } + }, + alertEvents: { + title: 'Eventos de alerta', + description: 'Registros recientes de alertas activadas y resueltas (solo por correo)', + loading: 'Cargando...', + empty: 'No hay eventos de alerta', + loadFailed: 'No se pudieron cargar los eventos de alerta', + status: { + firing: 'ACTIVA', + resolved: 'RESUELTA', + manualResolved: 'RESUELTA A MANO' + }, + detail: { + title: 'Detalle de la alerta', + loading: 'Cargando el detalle...', + empty: 'Sin detalle', + loadFailed: 'No se pudo cargar el detalle de la alerta', + manualResolve: 'Marcar como resuelta', + manualResolvedSuccess: 'Marcada como resuelta a mano', + manualResolvedFailed: 'No se pudo marcar como resuelta a mano', + silence: 'Ignorar la alerta', + silenceSuccess: 'Alerta silenciada', + silenceFailed: 'No se pudo silenciar la alerta', + viewRule: 'Ver la regla', + viewLogs: 'Ver los registros', + firedAt: 'Se activó el', + resolvedAt: 'Se resolvió el', + ruleId: 'ID de la regla', + dimensions: 'Dimensiones', + historyTitle: 'Historial', + historyHint: 'Eventos recientes con la misma regla y dimensiones', + historyLoading: 'Cargando el historial...', + historyEmpty: 'Sin historial' + }, + table: { + time: 'Hora', + status: 'Estado', + severity: 'Gravedad', + platform: 'Plataforma', + ruleId: 'ID de la regla', + title: 'Título', + duration: 'Duración', + metric: 'Métrica / Umbral', + dimensions: 'Dimensiones', + email: 'Correo enviado', + emailSent: 'Enviado', + emailIgnored: 'Ignorado' + } + }, + alertRules: { + title: 'Reglas de alerta', + description: 'Crea y gestiona alertas del sistema por umbrales (solo por correo)', + loading: 'Cargando...', + empty: 'No hay reglas de alerta', + loadFailed: 'No se pudieron cargar las reglas de alerta', + saveFailed: 'No se pudo guardar la regla de alerta', + saveSuccess: 'Regla de alerta guardada correctamente', + deleteFailed: 'No se pudo eliminar la regla de alerta', + deleteSuccess: 'Regla de alerta eliminada correctamente', + manage: 'Gestionar las reglas de alerta', + create: 'Crear una regla', + createTitle: 'Crear una regla de alerta', + editTitle: 'Editar la regla de alerta', + deleteConfirmTitle: '¿Eliminar esta regla?', + deleteConfirmMessage: 'Esto eliminará la regla y sus eventos relacionados. ¿Continuar?', + metricGroups: { + system: 'Métricas del sistema', + group: 'Métricas por grupo (requiere group_id)', + account: 'Métricas por cuenta' + }, + metrics: { + successRate: 'Tasa de éxito (%)', + errorRate: 'Tasa de error (%)', + upstreamErrorRate: 'Tasa de error del proveedor (%)', + p95: 'Latencia P95 (ms)', + p99: 'Latencia P99 (ms)', + cpu: 'Uso de CPU (%)', + memory: 'Uso de memoria (%)', + queueDepth: 'Profundidad de la cola de concurrencia', + groupAvailableAccounts: 'Cuentas disponibles del grupo', + groupAvailableRatio: 'Proporción de cuentas disponibles del grupo (%)', + groupRateLimitRatio: 'Proporción de cuentas del grupo con límite alcanzado (%)', + accountRateLimitedCount: 'Cuentas con el límite alcanzado', + accountErrorCount: 'Cuentas con error (sin contar las temporalmente no planificables)', + accountErrorRatio: 'Proporción de cuentas con error (%)', + accountTempUnscheduledCount: 'Cuentas temporalmente no planificables', + overloadAccountCount: 'Cuentas sobrecargadas' + }, + metricDescriptions: { + successRate: 'Porcentaje de peticiones correctas en la ventana (0-100).', + errorRate: 'Porcentaje de peticiones fallidas en la ventana (0-100).', + upstreamErrorRate: 'Porcentaje de fallos del proveedor en la ventana (0-100).', + p95: 'Latencia P95 de las peticiones dentro de la ventana (ms).', + p99: 'Latencia P99 de las peticiones dentro de la ventana (ms).', + cpu: 'Uso de CPU actual de la instancia (0-100).', + memory: 'Uso de memoria actual de la instancia (0-100).', + queueDepth: 'Profundidad de la cola de concurrencia dentro de la ventana (peticiones en cola).', + groupAvailableAccounts: 'Número de cuentas disponibles en el grupo seleccionado (requiere group_id).', + groupAvailableRatio: 'Proporción de cuentas disponibles en el grupo seleccionado (0-100, requiere group_id).', + groupRateLimitRatio: 'Proporción de cuentas con el límite alcanzado en el grupo seleccionado (0-100, requiere group_id).', + accountRateLimitedCount: 'Número de cuentas con el límite alcanzado dentro de la ventana.', + accountErrorCount: 'Número de cuentas con error dentro de la ventana (sin contar las temporalmente no planificables).', + accountErrorRatio: 'Proporción de cuentas con error dentro de la ventana (0-100).', + accountTempUnscheduledCount: 'Número de cuentas temporalmente no planificables (p. ej. expulsadas automáticamente por fallo del proxy o de las credenciales).', + overloadAccountCount: 'Número de cuentas sobrecargadas dentro de la ventana.' + }, + hints: { + recommended: 'Recomendado: operador {operator}, umbral {threshold}{unit}', + groupRequired: 'Esta es una métrica por grupo; hay que seleccionar un grupo (group_id).', + groupOptional: 'Opcional: limita la regla a un grupo concreto con group_id.' + }, + table: { + name: 'Nombre', + metric: 'Métrica', + severity: 'Gravedad', + enabled: 'Activada', + actions: 'Acciones' + }, + form: { + name: 'Nombre', + description: 'Descripción', + metric: 'Métrica', + operator: 'Operador', + groupId: 'Grupo (group_id)', + groupPlaceholder: 'Selecciona un grupo', + allGroups: 'Todos los grupos', + threshold: 'Umbral', + severity: 'Gravedad', + window: 'Ventana (minutos)', + sustained: 'Sostenido (muestras)', + cooldown: 'Enfriamiento (minutos)', + enabled: 'Activada', + notifyEmail: 'Enviar notificaciones por correo' + }, + validation: { + title: 'Corrige los siguientes problemas', + invalid: 'Regla no válida', + nameRequired: 'El nombre es obligatorio', + metricRequired: 'La métrica es obligatoria', + groupIdRequired: 'group_id es obligatorio en las métricas por grupo', + operatorRequired: 'El operador es obligatorio', + thresholdRequired: 'El umbral debe ser un número', + windowRange: 'La ventana debe ser 1, 5 o 60 minutos', + sustainedRange: 'El valor sostenido debe estar entre 1 y 1440 muestras', + cooldownRange: 'El enfriamiento debe estar entre 0 y 1440 minutos' + } + }, + runtime: { + title: 'Ajustes de ejecución de operaciones', + description: 'Se guardan en la base de datos; los cambios se aplican sin editar archivos de configuración.', + loading: 'Cargando...', + noData: 'No hay ajustes de ejecución disponibles', + loadFailed: 'No se pudieron cargar los ajustes de ejecución', + saveSuccess: 'Ajustes de ejecución guardados', + saveFailed: 'No se pudieron guardar los ajustes de ejecución', + alertTitle: 'Evaluador de alertas', + groupAvailabilityTitle: 'Monitor de disponibilidad de grupos', + evalIntervalSeconds: 'Intervalo de evaluación (segundos)', + silencing: { + title: 'Silenciar alertas (modo mantenimiento)', + enabled: 'Activar el silenciado', + globalUntil: 'Silenciar hasta (RFC3339)', + untilHint: 'Déjalo vacío para activar el silenciado sin caducidad (no recomendado).', + reason: 'Motivo', + reasonPlaceholder: 'p. ej. mantenimiento planificado', + entries: { + title: 'Avanzado: silenciado selectivo', + hint: 'Opcional: silencia solo ciertas reglas o gravedades. Deja los campos vacíos para que coincidan todas.', + add: 'Añadir una entrada', + empty: 'No hay entradas selectivas', + entryTitle: 'Entrada n.º {n}', + ruleId: 'ID de la regla (opcional)', + ruleIdPlaceholder: 'p. ej. 1', + severities: 'Gravedades (opcional)', + severitiesPlaceholder: 'p. ej. P0,P1 (vacío = todas)', + until: 'Hasta (RFC3339)', + reason: 'Motivo', + validation: { + untilRequired: 'La hora «hasta» de la entrada es obligatoria', + untilFormat: 'La hora «hasta» de la entrada debe ser una marca de tiempo RFC3339 válida', + ruleIdPositive: 'El rule_id de la entrada debe ser un entero positivo', + severitiesFormat: 'Las gravedades de la entrada deben ser una lista separada por comas de P0..P3' + } + }, + validation: { + timeFormat: 'La hora de silenciado debe ser una marca de tiempo RFC3339 válida' + } + }, + lockEnabled: 'Bloqueo distribuido activado', + lockKey: 'Clave del bloqueo distribuido', + lockTTLSeconds: 'TTL del bloqueo distribuido (segundos)', + showAdvancedDeveloperSettings: 'Mostrar los ajustes avanzados de desarrollo (bloqueo distribuido)', + advancedSettingsSummary: 'Ajustes avanzados (bloqueo distribuido)', + evalIntervalHint: 'Cada cuánto se ejecuta el evaluador. Se recomienda dejar el valor por defecto.', + validation: { + title: 'Corrige los siguientes problemas', + invalid: 'Ajustes no válidos', + evalIntervalRange: 'El intervalo de evaluación debe estar entre 1 y 86400 segundos', + lockKeyRequired: 'La clave del bloqueo distribuido es obligatoria cuando el bloqueo está activado', + lockKeyPrefix: 'La clave del bloqueo distribuido debe empezar por «{prefix}»', + lockKeyHint: 'Recomendado: empezar por «{prefix}» para evitar conflictos', + lockTtlRange: 'El TTL del bloqueo distribuido debe estar entre 1 y 86400 segundos', + slaMinPercentRange: 'El porcentaje mínimo de SLA debe estar entre 0 y 100', + ttftP99MaxRange: 'El máximo de TTFT P99 debe ser un número ≥ 0', + requestErrorRateMaxRange: 'La tasa máxima de error de petición debe estar entre 0 y 100', + upstreamErrorRateMaxRange: 'La tasa máxima de error del proveedor debe estar entre 0 y 100' + } + }, + email: { + title: 'Notificaciones por correo', + description: 'Configura las notificaciones por correo de alertas e informes (se guardan en la base de datos).', + loading: 'Cargando...', + noData: 'No hay configuración de notificaciones por correo', + loadFailed: 'No se pudo cargar la configuración de notificaciones por correo', + saveSuccess: 'Configuración de notificaciones por correo guardada', + saveFailed: 'No se pudo guardar la configuración de notificaciones por correo', + alertTitle: 'Correos de alerta', + reportTitle: 'Correos de informe', + recipients: 'Destinatarios', + recipientsHint: 'Si se deja vacío, el sistema puede usar el correo del primer administrador.', + minSeverity: 'Gravedad mínima', + minSeverityAll: 'Todas las gravedades', + rateLimitPerHour: 'Límite por hora', + batchWindowSeconds: 'Ventana de agrupación (segundos)', + includeResolved: 'Incluir las alertas resueltas', + dailySummary: 'Resumen diario', + weeklySummary: 'Resumen semanal', + errorDigest: 'Resumen de errores', + errorDigestMinCount: 'Errores mínimos para el resumen', + accountHealth: 'Salud de las cuentas', + accountHealthThreshold: 'Umbral de tasa de error (%)', + cronPlaceholder: 'Expresión cron', + reportHint: 'Las programaciones usan sintaxis cron; déjalas vacías para usar los valores por defecto.', + validation: { + title: 'Corrige los siguientes problemas', + invalid: 'Configuración de notificaciones por correo no válida', + alertRecipientsRequired: 'Los correos de alerta están activados pero no hay destinatarios configurados', + reportRecipientsRequired: 'Los correos de informe están activados pero no hay destinatarios configurados', + invalidRecipients: 'Uno o más correos de destinatario no son válidos', + rateLimitRange: 'El límite por hora debe ser un número ≥ 0', + batchWindowRange: 'La ventana de agrupación debe estar entre 0 y 86400 segundos', + cronRequired: 'Hace falta una expresión cron cuando la programación está activada', + cronFormat: 'El formato de la expresión cron parece no ser válido (se esperan al menos 5 partes)', + digestMinCountRange: 'Los errores mínimos para el resumen deben ser un número ≥ 0', + accountHealthThresholdRange: 'El umbral de salud de las cuentas debe estar entre 0 y 100' + } + }, + settings: { + title: 'Ajustes de la monitorización de operaciones', + loadFailed: 'No se pudieron cargar los ajustes', + saveSuccess: 'Ajustes de monitorización guardados correctamente', + saveFailed: 'No se pudieron guardar los ajustes', + dataCollection: 'Recogida de datos', + evaluationInterval: 'Intervalo de evaluación (segundos)', + evaluationIntervalHint: 'Frecuencia de las tareas de detección, se recomienda dejar el valor por defecto', + alertConfig: 'Configuración de alertas', + enableAlert: 'Activar las alertas', + alertRecipients: 'Correos que reciben las alertas', + emailPlaceholder: 'Introduce la dirección de correo', + recipientsHint: 'Si se deja vacío, el sistema usará el correo del primer administrador como destinatario por defecto', + minSeverity: 'Gravedad mínima', + reportConfig: 'Configuración de informes', + enableReport: 'Activar los informes', + reportRecipients: 'Correos que reciben los informes', + dailySummary: 'Resumen diario', + weeklySummary: 'Resumen semanal', + metricThresholds: 'Umbrales de las métricas', + metricThresholdsHint: 'Configura los umbrales de alerta de las métricas; los valores que los superen se mostrarán en rojo', + slaMinPercent: 'Porcentaje mínimo de SLA', + slaMinPercentHint: 'Un SLA por debajo de este valor se mostrará en rojo (por defecto: 99,5 %)', + ttftP99MaxMs: 'Máximo de TTFT P99 (ms)', + ttftP99MaxMsHint: 'Un TTFT P99 por encima de este valor se mostrará en rojo (por defecto: 500 ms)', + requestErrorRateMaxPercent: 'Tasa máxima de error de petición (%)', + requestErrorRateMaxPercentHint: 'Una tasa de error de petición por encima de este valor se mostrará en rojo (por defecto: 5 %)', + upstreamErrorRateMaxPercent: 'Tasa máxima de error del proveedor (%)', + upstreamErrorRateMaxPercentHint: 'Una tasa de error del proveedor por encima de este valor se mostrará en rojo (por defecto: 5 %)', + advancedSettings: 'Ajustes avanzados', + dataRetention: 'Política de retención de datos', + enableCleanup: 'Activar la limpieza de datos', + cleanupSchedule: 'Programación de la limpieza (cron)', + cleanupScheduleHint: 'Ejemplo: 0 2 * * * significa a las 2 de la madrugada todos los días', + errorLogRetentionDays: 'Días de retención de los registros de error', + minuteMetricsRetentionDays: 'Días de retención de las métricas por minuto', + hourlyMetricsRetentionDays: 'Días de retención de las métricas por hora', + retentionDaysHint: 'Se recomiendan entre 7 y 90 días; los periodos más largos consumen más almacenamiento. Pon 0 para borrar todo el histórico en cada limpieza programada', + aggregation: 'Tareas de preagregación', + enableAggregation: 'Activar la preagregación', + aggregationHint: 'La preagregación mejora el rendimiento de las consultas en ventanas de tiempo largas', + openaiQuotaAutoPause: 'Pausa automática por cuota en cuentas de OpenAI', + openaiQuotaAutoPauseHint: 'Cuando una cuenta de OpenAI alcanza su umbral de consumo de 5 h o 7 d, el planificador la salta automáticamente y la reanuda cuando la ventana se renueva. Los umbrales por cuenta tienen prioridad sobre este valor global.', + openaiQuotaAutoPauseDefault5h: 'Umbral de consumo de 5 h por defecto (%)', + openaiQuotaAutoPauseDefault7d: 'Umbral de consumo de 7 d por defecto (%)', + openaiQuotaAutoPauseThresholdHint: 'Valor de 0 a 100; déjalo vacío o en 0 para desactivar el umbral global por defecto.', + errorFiltering: 'Filtrado de errores', + ignoreCountTokensErrors: 'Ignorar los errores de count_tokens', + ignoreCountTokensErrorsHint: 'Si se activa, los errores de las peticiones count_tokens no se escribirán en el registro de errores.', + ignoreContextCanceled: 'Ignorar los errores de desconexión del cliente', + ignoreContextCanceledHint: 'Si se activa, los errores de desconexión del cliente (context canceled) no se escribirán en el registro de errores.', + ignoreNoAvailableAccounts: 'Ignorar los errores de «no hay cuentas disponibles»', + ignoreNoAvailableAccountsHint: 'Si se activa, los errores «No available accounts» no se escribirán en el registro de errores (no recomendado; suele ser un problema de configuración).', + ignoreInsufficientBalanceErrors: 'Ignorar los errores de saldo insuficiente', + ignoreInsufficientBalanceErrorsHint: 'Si se activa, los errores de saldo insuficiente de la cuenta no se escribirán en el registro de errores.', + autoRefresh: 'Actualización automática', + enableAutoRefresh: 'Activar la actualización automática', + enableAutoRefreshHint: 'Actualiza automáticamente los datos del panel a intervalos fijos.', + refreshInterval: 'Intervalo de actualización', + refreshInterval15s: '15 segundos', + refreshInterval30s: '30 segundos', + refreshInterval60s: '60 segundos', + dashboardCards: 'Tarjetas del panel', + displayAlertEvents: 'Mostrar los eventos de alerta', + displayAlertEventsHint: 'Muestra u oculta la tarjeta de eventos de alerta recientes en el panel de operaciones. Activada por defecto.', + displayOpenAITokenStats: 'Mostrar las estadísticas de peticiones de tokens de OpenAI', + displayOpenAITokenStatsHint: 'Muestra u oculta la tarjeta de estadísticas de tokens de OpenAI en el panel de operaciones. Oculta por defecto.', + autoRefreshCountdown: 'Actualización automática: {seconds} s', + validation: { + title: 'Corrige los siguientes problemas', + retentionDaysRange: 'Los días de retención deben estar entre 0 y 365 (0 = borrar todo en cada limpieza)', + slaMinPercentRange: 'El porcentaje mínimo de SLA debe estar entre 0 y 100', + ttftP99MaxRange: 'El máximo de TTFT P99 debe ser un número ≥ 0', + requestErrorRateMaxRange: 'La tasa máxima de error de petición debe estar entre 0 y 100', + upstreamErrorRateMaxRange: 'La tasa máxima de error del proveedor debe estar entre 0 y 100', + openaiQuotaAutoPauseRange: 'El umbral de pausa automática por cuota de OpenAI debe estar entre 0 y 100' + } + }, + concurrency: { + title: 'Concurrencia / Cola', + byPlatform: 'Por plataforma', + byGroup: 'Por grupo', + byAccount: 'Por cuenta', + byUser: 'Por usuario', + showByUserTooltip: 'Cambia a la vista por usuario para ver el uso de concurrencia de cada uno', + switchToUser: 'Cambiar a la vista por usuario', + switchToPlatform: 'Cambiar a la vista por plataforma', + totalRows: '{count} filas', + disabledHint: 'La monitorización en tiempo real está desactivada en los ajustes.', + empty: 'Sin datos', + queued: 'En cola {count}', + rateLimited: 'Con el límite alcanzado {count}', + errorAccounts: 'Errores {count}', + loadFailed: 'No se pudieron cargar los datos de concurrencia' + }, + realtime: { + title: 'Tiempo real', + connected: 'Tiempo real conectado', + connecting: 'Conectando el tiempo real', + reconnecting: 'Reconectando el tiempo real', + offline: 'Tiempo real desconectado', + closed: 'Tiempo real cerrado', + reconnectIn: 'reintento en {seconds} s' + }, + queryMode: { + auto: 'Automático', + raw: 'En bruto', + preagg: 'Preagregado' + }, + accountAvailability: { + available: 'Disponible', + unavailable: 'No disponible', + accountError: 'Error' + }, + tooltips: { + totalRequests: 'Número total de peticiones (correctas y fallidas) en la ventana de tiempo seleccionada.', + throughputTrend: 'Peticiones/QPS + Tokens/TPS en la ventana seleccionada.', + switchRateTrend: 'Tendencia de cambios de cuenta / peticiones totales en las últimas 5 horas (cambios medios).', + latencyHistogram: 'Reparto de la duración de las peticiones (ms) para las peticiones correctas.', + errorTrend: 'Número de errores a lo largo del tiempo (el alcance del SLA excluye los límites de negocio; el del proveedor excluye 429/529).', + errorDistribution: 'Reparto de errores por código de estado (alcance del SLA, sin los límites de negocio).', + goroutines: + 'Número de goroutines del entorno de ejecución de Go (hilos ligeros). No hay un número «seguro» absoluto: usa tu propia línea base histórica. Como orientación: menos de 2.000 es habitual; entre 2.000 y 8.000 conviene vigilar; más de 8.000 junto con cola y latencia crecientes suele indicar bloqueos o fugas.', + cpu: 'Porcentaje de uso de CPU, muestra la carga del procesador del sistema.', + memory: 'Uso de memoria, incluida la usada y la total disponible.', + db: 'Estado del grupo de conexiones a la base de datos, incluidas las activas, libres y en espera.', + redis: 'Estado del grupo de conexiones a Redis, muestra las conexiones activas y libres.', + jobs: 'Estado de ejecución de las tareas en segundo plano, incluidas la hora de la última ejecución, del último éxito y la información de errores.', + qps: 'Consultas por segundo (QPS) y tokens por segundo (TPS), el rendimiento del sistema en tiempo real.', + tokens: 'Número total de tokens procesados en la ventana de tiempo actual.', + sla: 'Tasa de éxito del acuerdo de nivel de servicio, sin contar los límites de negocio (p. ej. saldo insuficiente o cuota superada).', + errors: 'Estadísticas de errores, incluidos el total, la tasa de error y la tasa de error del proveedor.', + upstreamErrors: 'Estadísticas de errores del proveedor, sin contar los errores por límite de tasa (429/529).', + latency: 'Estadísticas de duración de las peticiones, incluidos los percentiles p50, p90, p95 y p99.', + ttft: 'Tiempo hasta el primer token, mide la rapidez con la que llega el primer token en las respuestas por flujo.', + health: 'Puntuación de salud del sistema (0-100), teniendo en cuenta el SLA, la tasa de error y el uso de recursos.' + }, + charts: { + emptyRequest: 'No hay peticiones en esta ventana.', + emptyError: 'No hay errores en esta ventana.', + resetZoom: 'Restablecer', + resetZoomHint: 'Restablecer el zoom (si está activado)', + downloadChart: 'Descargar', + downloadChartHint: 'Descargar el gráfico como imagen' + } + }, + + // Settings +} diff --git a/frontend/src/i18n/locales/es/admin/overview.ts b/frontend/src/i18n/locales/es/admin/overview.ts new file mode 100644 index 00000000000..05c59b4e4be --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/overview.ts @@ -0,0 +1,1205 @@ +export default { + // Dashboard + dashboard: { + title: 'Panel de administración', + description: 'Resumen del sistema y estadísticas en tiempo real', + apiKeys: 'Claves API', + totalApiKeys: 'Claves API totales', + activeApiKeys: 'Claves activas', + accounts: 'Cuentas', + totalAccounts: 'Cuentas totales', + activeAccounts: 'Cuentas activas', + users: 'Usuarios', + totalUsers: 'Usuarios totales', + todayRequests: 'Peticiones de hoy', + totalRequests: 'Peticiones totales', + todayCost: 'Coste de hoy', + totalCost: 'Coste total', + newUsersToday: 'Usuarios nuevos hoy', + todayTokens: 'Tokens de hoy', + totalTokens: 'Tokens totales', + input: 'Entrada', + output: 'Salida', + cacheToday: 'Caché (hoy)', + performance: 'Rendimiento', + avgResponse: 'Respuesta media', + averageTime: 'Tiempo medio', + active: 'activas', + ok: 'ok', + err: 'err', + activeUsers: 'usuarios activos', + create: 'Crear', + timeRange: 'Rango de tiempo', + granularity: 'Granularidad', + day: 'Día', + hour: 'Hora', + modelDistribution: 'Reparto por modelo', + groupDistribution: 'Reparto del consumo por grupo', + metricTokens: 'Por tokens', + metricActualCost: 'Por coste real', + tokenUsageTrend: 'Tendencia de consumo de tokens', + userUsageTrend: 'Tendencia de consumo por usuario (top 12)', + model: 'Modelo', + group: 'Grupo', + noGroup: 'Sin grupo', + requests: 'Peticiones', + tokens: 'Tokens', + cache: 'Caché', + actual: 'Real', + standard: 'Estándar', + accountCost: 'Coste', + noDataAvailable: 'No hay datos disponibles', + recentUsage: 'Consumo reciente', + viewModelDistribution: 'Reparto por modelo', + viewSpendingRanking: 'Ranking de gasto por usuario', + spendingRankingTitle: 'Ranking de gasto por usuario', + spendingRankingUser: 'Usuario', + spendingRankingRequests: 'Peticiones', + spendingRankingTokens: 'Tokens', + spendingRankingSpend: 'Gasto', + spendingRankingOther: 'Otros', + spendingRankingUsage: 'Consumo', + spendShort: 'Gasto', + requestsShort: 'Pet.', + tokensShort: 'Tok.', + last7Days: 'Últimos 7 días', + noUsageRecords: 'No hay registros de consumo', + startUsingApi: 'En cuanto empieces a usar la API, tu historial de consumo aparecerá aquí.', + viewAllUsage: 'Ver todo', + quickActions: 'Acciones rápidas', + manageUsers: 'Gestionar usuarios', + viewUserAccounts: 'Consulta y gestiona las cuentas de usuario', + manageAccounts: 'Gestionar cuentas', + configureAiAccounts: 'Configura las cuentas de las plataformas de IA', + batchImage: 'Imágenes por lotes', + batchImageDesc: 'Envía trabajos y copia las instrucciones para el agente', + groupPricing: 'Precios por grupo', + groupPricingDesc: 'Configura el descuento por lotes y la proporción de retención', + systemSettings: 'Ajustes del sistema', + configureSystem: 'Configura los ajustes del sistema', + failedToLoad: 'No se pudieron cargar las estadísticas del panel' + }, + + backup: { + title: 'Copia de seguridad de la base de datos', + description: 'Copia completa de la base de datos a un almacenamiento compatible con S3, con programación y restauración', + s3: { + title: 'Configuración del almacenamiento S3', + description: 'Configura un almacenamiento compatible con S3 (admite Cloudflare R2)', + descriptionPrefix: 'Configura un almacenamiento compatible con S3 (admite', + descriptionSuffix: ')', + enabled: 'Activar el almacenamiento S3', + endpoint: 'Endpoint', + region: 'Región', + bucket: 'Bucket', + prefix: 'Prefijo de clave', + accessKeyId: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + secretConfigured: 'Ya está configurada, déjalo vacío para conservarla', + forcePathStyle: 'Forzar el estilo de ruta', + testConnection: 'Probar la conexión', + testSuccess: 'La prueba de conexión con S3 salió bien', + testFailed: 'Falló la prueba de conexión con S3', + saved: 'Configuración de S3 guardada' + }, + imageStorage: { + title: 'Almacenamiento de objetos para imágenes asíncronas', + description: 'Activa los endpoints de imagen asíncronos y descarga las imágenes generadas al almacenamiento de objetos, dejando en Redis solo enlaces cortos. Comparte el cliente S3 con las copias de seguridad y se aplica al guardar, sin reiniciar.', + enabled: 'Activar las tareas de imagen asíncronas', + reuseBackupS3: 'Reutilizar la configuración S3 de las copias de seguridad de arriba (solo cambian bucket y prefijo)', + bucket: 'Bucket', + bucketInherited: 'Déjalo vacío para usar el bucket de las copias de seguridad', + prefix: 'Prefijo de clave', + publicBaseUrl: 'URL base pública', + publicBaseUrlPlaceholder: 'Déjalo vacío para devolver enlaces prefirmados', + presignExpiryHours: 'Duración de los enlaces prefirmados (horas)', + saved: 'Almacenamiento de objetos para imágenes asíncronas guardado' + }, + schedule: { + title: 'Copia de seguridad programada', + description: 'Configura las copias de seguridad automáticas', + enabled: 'Activar la copia de seguridad programada', + cronExpr: 'Expresión cron', + cronHint: 'p. ej. «0 2 * * *» significa todos los días a las 2 de la madrugada', + retainDays: 'Días hasta que caduca la copia', + retainDaysHint: 'Los archivos de copia se borran solos pasados estos días, 0 = no caducan nunca', + retainCount: 'Número máximo a conservar', + retainCountHint: 'Número máximo de copias que se conservan, 0 = sin límite', + saved: 'Configuración de la programación guardada' + }, + operations: { + title: 'Registros de copias de seguridad', + description: 'Crea copias manuales y gestiona los registros de copias existentes', + createBackup: 'Crear una copia', + backing: 'Copiando...', + backupCreated: 'Copia de seguridad creada correctamente', + expireDays: 'Días hasta caducar', + alreadyInProgress: 'Ya hay una copia de seguridad en curso', + backupRunning: 'Copia de seguridad en curso...', + backupFailed: 'Falló la copia de seguridad', + restoreRunning: 'Restauración en curso...', + restoreFailed: 'Falló la restauración', + }, + columns: { + status: 'Estado', + fileName: 'Nombre del archivo', + size: 'Tamaño', + parts: 'Partes', + expiresAt: 'Caduca el', + triggeredBy: 'Lanzada por', + startedAt: 'Empezó el', + actions: 'Acciones' + }, + status: { + pending: 'Pendiente', + running: 'En curso', + completed: 'Completada', + failed: 'Fallida' + }, + progress: { + pending: 'Preparando', + dumping: 'Volcando la base de datos', + uploading: 'Subiendo', + }, + trigger: { + manual: 'Manual', + scheduled: 'Programada' + }, + neverExpire: 'Nunca', + empty: 'No hay registros de copias de seguridad', + actions: { + download: 'Descargar', + downloadParts: 'Descargar las partes', + downloadPartsHint: 'Descarga todas las partes en orden y concatena los bytes gzip: en Linux o macOS ejecuta cat payload.part-* > backup.sql.gz; en Windows ejecuta copy /b payload.part-000001+payload.part-000002 backup.sql.gz.', + partLabel: 'Parte {index}', + downloadFailed: 'La URL de descarga está vacía', + restore: 'Restaurar', + restoreConfirm: '¿Seguro que quieres restaurar desde esta copia? ¡Esto sobrescribirá la base de datos actual!', + restorePasswordPrompt: 'Introduce tu contraseña de administrador para confirmar la restauración', + restoreSuccess: 'Base de datos restaurada correctamente', + deleteConfirm: '¿Seguro que quieres eliminar esta copia de seguridad?', + deleted: 'Copia de seguridad eliminada' + }, + r2Guide: { + title: 'Guía de configuración de Cloudflare R2', + intro: 'Cloudflare R2 ofrece almacenamiento de objetos compatible con S3 con un plan gratuito de 10 GB y 1 millón de peticiones de clase A al mes, ideal para copias de seguridad de bases de datos.', + step1: { + title: 'Crear un bucket de R2', + line1: 'Entra en el panel de Cloudflare (dash.cloudflare.com) y elige «R2 Object Storage» en la barra lateral', + line2: 'Pulsa «Create bucket», ponle un nombre (p. ej. sub2api-backups) y elige una región', + line3: 'Pulsa crear para terminar' + }, + step2: { + title: 'Crear un token de API', + line1: 'En la página de R2, pulsa «Manage R2 API Tokens» arriba a la derecha', + line2: 'Pulsa «Create API token» y pon el permiso en «Object Read & Write»', + line3: 'Recomendado: restringirlo a un bucket concreto para más seguridad', + line4: 'Al crearlo verás el Access Key ID y el Secret Access Key', + warning: 'El Secret Access Key solo se muestra una vez: ¡cópialo y guárdalo de inmediato!' + }, + step3: { + title: 'Obtener el endpoint de S3', + desc: 'Busca tu Account ID en la página de resumen de R2 (en la URL o en el panel derecho). El formato del endpoint es:', + accountId: 'tu_account_id' + }, + step4: { + title: 'Rellenar la configuración', + checkEnabled: 'Marcado', + bucketValue: 'El nombre de tu bucket', + fromStep2: 'Valor del paso 2', + unchecked: 'Sin marcar' + }, + freeTier: 'Plan gratuito de R2: 10 GB de almacenamiento + 1 millón de peticiones de clase A + 10 millones de clase B al mes, más que suficiente para copias de bases de datos.' + } + }, + + dataManagement: { + title: 'Gestión de datos', + description: 'Gestiona en un mismo sitio el estado del agente de gestión de datos, los ajustes de almacenamiento de objetos y las tareas de copia de seguridad', + agent: { + title: 'Estado del agente de gestión de datos', + description: 'El sistema comprueba un socket Unix fijo y solo activa la gestión de datos cuando responde.', + enabled: 'El agente de gestión de datos está listo. Las operaciones de gestión de datos están disponibles.', + disabled: 'El agente de gestión de datos no está disponible. Ahora mismo solo se puede consultar información de diagnóstico.', + socketPath: 'Ruta del socket', + version: 'Versión', + status: 'Estado', + uptime: 'Tiempo en funcionamiento', + reasonLabel: 'Motivo de la indisponibilidad', + reason: { + DATA_MANAGEMENT_AGENT_SOCKET_MISSING: 'Falta el archivo de socket de gestión de datos', + DATA_MANAGEMENT_AGENT_UNAVAILABLE: 'No se puede contactar con el agente de gestión de datos', + BACKUP_AGENT_SOCKET_MISSING: 'Falta el archivo de socket de copias de seguridad', + BACKUP_AGENT_UNAVAILABLE: 'No se puede contactar con el agente de copias de seguridad', + UNKNOWN: 'Motivo desconocido' + } + }, + sections: { + config: { + title: 'Configuración de las copias de seguridad', + description: 'Configura el origen de la copia, la política de retención y los ajustes de S3.' + }, + s3: { + title: 'Almacenamiento de objetos S3', + description: 'Configura y prueba la subida de los archivos de copia a un almacenamiento compatible con S3.' + }, + backup: { + title: 'Operaciones de copia de seguridad', + description: 'Lanza tareas de copia de PostgreSQL, Redis y completas.' + }, + history: { + title: 'Historial de copias de seguridad', + description: 'Revisa el estado de las tareas de copia, los errores y los metadatos de los archivos.' + } + }, + form: { + sourceMode: 'Modo de origen', + backupRoot: 'Carpeta raíz de las copias', + activePostgresProfile: 'Perfil de PostgreSQL activo', + activeRedisProfile: 'Perfil de Redis activo', + activeS3Profile: 'Perfil de S3 activo', + retentionDays: 'Días de retención', + keepLast: 'Últimas tareas a conservar', + uploadToS3: 'Subir a S3', + useActivePostgresProfile: 'Usar el perfil de PostgreSQL activo', + useActiveRedisProfile: 'Usar el perfil de Redis activo', + useActiveS3Profile: 'Usar el perfil activo', + idempotencyKey: 'Clave de idempotencia (opcional)', + secretConfigured: 'Ya está configurada, déjalo vacío para no cambiarla', + source: { + profileID: 'ID del perfil (único)', + profileName: 'Nombre del perfil', + setActive: 'Marcar como activo al crearlo' + }, + postgres: { + title: 'PostgreSQL', + host: 'Servidor', + port: 'Puerto', + user: 'Usuario', + password: 'Contraseña', + database: 'Base de datos', + sslMode: 'Modo SSL', + containerName: 'Nombre del contenedor (modo docker_exec)' + }, + redis: { + title: 'Redis', + addr: 'Dirección (servidor:puerto)', + username: 'Usuario', + password: 'Contraseña', + db: 'Índice de la base de datos', + containerName: 'Nombre del contenedor (modo docker_exec)' + }, + s3: { + enabled: 'Activar la subida a S3', + profileID: 'ID del perfil (único)', + profileName: 'Nombre del perfil', + endpoint: 'Endpoint (opcional)', + region: 'Región', + bucket: 'Bucket', + accessKeyID: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + prefix: 'Prefijo de los objetos', + forcePathStyle: 'Forzar el estilo de ruta', + useSSL: 'Usar SSL', + setActive: 'Marcar como activo al crearlo' + } + }, + sourceProfiles: { + createTitle: 'Crear un perfil de origen', + editTitle: 'Editar el perfil de origen', + empty: 'Todavía no hay perfiles de origen, crea uno primero', + deleteConfirm: '¿Eliminar el perfil de origen {profileID}?', + columns: { + profile: 'Perfil', + active: 'Activo', + connection: 'Conexión', + database: 'Base de datos', + updatedAt: 'Actualizado el', + actions: 'Acciones' + } + }, + s3Profiles: { + createTitle: 'Crear un perfil de S3', + editTitle: 'Editar el perfil de S3', + empty: 'Todavía no hay perfiles de S3, crea uno primero', + editHint: 'Pulsa «Editar» para modificar los datos del perfil en el panel de la derecha.', + deleteConfirm: '¿Eliminar el perfil de S3 {profileID}?', + columns: { + profile: 'Perfil', + active: 'Activo', + storage: 'Almacenamiento', + updatedAt: 'Actualizado el', + actions: 'Acciones' + } + }, + history: { + total: '{count} tareas', + empty: 'Todavía no hay tareas de copia de seguridad', + columns: { + jobID: 'ID de la tarea', + type: 'Tipo', + status: 'Estado', + triggeredBy: 'Lanzada por', + pgProfile: 'Perfil de PostgreSQL', + redisProfile: 'Perfil de Redis', + s3Profile: 'Perfil de S3', + finishedAt: 'Terminó el', + artifact: 'Archivo generado', + error: 'Error' + }, + status: { + queued: 'En cola', + running: 'En curso', + succeeded: 'Correcta', + failed: 'Fallida', + partial_succeeded: 'Parcialmente correcta' + } + }, + actions: { + refresh: 'Actualizar el estado', + disabledHint: 'Arranca primero datamanagementd y asegúrate de que el socket responde.', + reloadConfig: 'Recargar la configuración', + reloadSourceProfiles: 'Recargar los perfiles de origen', + reloadProfiles: 'Recargar los perfiles', + newSourceProfile: 'Nuevo perfil de origen', + saveConfig: 'Guardar la configuración', + configSaved: 'Configuración guardada', + testS3: 'Probar la conexión con S3', + s3TestOK: 'La prueba de conexión con S3 salió bien', + s3TestFailed: 'Falló la prueba de conexión con S3', + newProfile: 'Nuevo perfil', + saveProfile: 'Guardar el perfil', + activateProfile: 'Activar', + profileIDRequired: 'El ID del perfil es obligatorio', + profileNameRequired: 'El nombre del perfil es obligatorio', + profileSelectRequired: 'Selecciona primero un perfil para editarlo', + profileCreated: 'Perfil de S3 creado', + profileSaved: 'Perfil de S3 guardado', + profileActivated: 'Perfil de S3 activado', + profileDeleted: 'Perfil de S3 eliminado', + sourceProfileCreated: 'Perfil de origen creado', + sourceProfileSaved: 'Perfil de origen guardado', + sourceProfileActivated: 'Perfil de origen activado', + sourceProfileDeleted: 'Perfil de origen eliminado', + createBackup: 'Crear una tarea de copia', + jobCreated: 'Tarea de copia creada: {jobID} ({status})', + refreshJobs: 'Actualizar las tareas', + loadMore: 'Cargar más' + } + }, + + affiliates: { + invitesDescription: 'Consulta las relaciones entre quien invita y quien es invitado en todo el sitio', + rebatesDescription: 'Consulta los pedidos de recarga que generaron comisiones de afiliado', + transfersDescription: 'Consulta las transferencias de comisiones al saldo de la cuenta', + errors: { + loadFailed: 'No se pudieron cargar los registros de afiliados' + }, + records: { + search: 'Buscar', + searchPlaceholder: 'Correo, nombre de usuario, ID de usuario o número de pedido', + startAt: 'Fecha de inicio', + endAt: 'Fecha de fin', + inviter: 'Quien invita', + invitee: 'Invitado', + user: 'Usuario', + affCode: 'Código de invitación', + order: 'Pedido', + totalRebate: 'Comisión total', + orderAmount: 'Importe de la recarga', + payAmount: 'Importe pagado', + rebateAmount: 'Importe de la comisión', + paymentType: 'Método de pago', + orderStatus: 'Estado del pedido', + transferAmount: 'Importe transferido', + balanceAfter: 'Saldo después', + availableQuotaAfter: 'Disponible después', + frozenQuotaAfter: 'Retenido después', + historyQuotaAfter: 'Comisiones históricas después', + invitedAt: 'Invitado el', + rebatedAt: 'Comisión generada el', + transferredAt: 'Transferido el' + }, + overview: { + title: 'Resumen del usuario afiliado', + affCode: 'Código de invitación', + rebateRate: 'Porcentaje de comisión', + invitedCount: 'Usuarios invitados', + rebatedInviteeCount: 'Invitados que generaron comisión', + availableQuota: 'Comisiones disponibles', + historyQuota: 'Comisiones históricas' + } + }, + + // Users + users: { + title: 'Gestión de usuarios', + description: 'Gestiona los usuarios y sus permisos', + createUser: 'Crear un usuario', + bulkLimits: { + action: 'Definir límites ({count})', + title: 'Definir los límites de los usuarios', + selectedCount: '{count} usuarios seleccionados', + selectionLimit: 'No selecciones más de {max} usuarios a la vez.', + selectUser: 'Seleccionar {email}', + enableConcurrency: 'Actualizar la concurrencia', + enableRPMLimit: 'Actualizar el límite de RPM', + unlimited: 'Ilimitado', + nonNegativeInteger: 'Introduce un número entero no negativo.', + apply: 'Aplicar los límites', + applying: 'Aplicando...', + concurrencyValue: 'Concurrencia: {value}', + rpmValue: 'RPM: {value}', + rpmUnlimitedValue: 'RPM: ilimitado', + confirm: '¿Sobrescribir los límites de {count} usuarios?\n{fields}', + success: 'Se actualizaron los límites de {count} usuarios', + failed: 'No se pudieron actualizar los límites de los usuarios' + }, + editUser: 'Editar el usuario', + deleteUser: 'Eliminar el usuario', + deleteConfirmMessage: '¿Seguro que quieres eliminar al usuario «{email}»? Esta acción no se puede deshacer.', + searchPlaceholder: 'Buscar por correo, nombre de usuario, notas o clave API...', + searchUsers: 'Buscar por correo, nombre de usuario, notas o clave API', + roleFilter: 'Filtro por rol', + allRoles: 'Todos los roles', + allStatus: 'Todos los estados', + allGroups: 'Todos los grupos', + searchGroups: 'Buscar grupos...', + fuzzySearch: 'Búsqueda aproximada', + apiKeyGroupFilter: 'Grupo de la clave API', + apiKeyGroupExclusive: 'Grupos exclusivos', + apiKeyGroupPublic: 'Grupos públicos', + apiKeyGroupSubscription: 'Grupos de suscripción', + apiKeyGroupDisabled: 'Grupos desactivados', + authorizedGroupFilter: 'Grupo autorizado', + allAuthorizedGroups: 'Todos los grupos autorizados', + searchAuthorizedGroups: 'Buscar grupos autorizados...', + allApiKeyGroups: 'Todos los grupos de claves API', + searchApiKeyGroups: 'Buscar grupos de claves API...', + statusFilter: 'Filtro por estado', + allStatuses: 'Todos los estados', + admin: 'Administrador', + user: 'Usuario', + disabled: 'Desactivado', + email: 'Correo electrónico', + password: 'Contraseña', + username: 'Nombre de usuario', + notes: 'Notas', + enterEmail: 'Introduce el correo', + enterPassword: 'Introduce la contraseña', + enterUsername: 'Introduce el nombre de usuario (opcional)', + enterNotes: 'Introduce notas (solo para administradores)', + notesHint: 'Esta nota solo la ven los administradores', + enterNewPassword: 'Introduce la contraseña nueva (opcional)', + leaveEmptyToKeep: 'Déjalo vacío para conservar la contraseña actual', + generatePassword: 'Generar una contraseña aleatoria', + copyPassword: 'Copiar la contraseña', + creating: 'Creando...', + updating: 'Actualizando...', + form: { + emailLabel: 'Correo electrónico', + emailPlaceholder: 'Introduce el correo', + usernameLabel: 'Nombre de usuario', + usernamePlaceholder: 'Introduce el nombre de usuario (opcional)', + notesLabel: 'Notas', + notesPlaceholder: 'Introduce notas (solo para administradores)', + notesHint: 'Esta nota solo la ven los administradores', + passwordLabel: 'Contraseña', + passwordPlaceholder: 'Introduce la contraseña (déjalo vacío para no cambiarla)', + roleLabel: 'Rol', + selectRole: 'Selecciona el rol', + balanceLabel: 'Saldo', + concurrencyLabel: 'Concurrencia', + statusLabel: 'Estado', + selectStatus: 'Selecciona el estado', + rpmLimit: 'Peticiones por minuto (RPM)', + rpmLimitPlaceholder: '0 = ilimitado', + rpmLimitHint: 'Máximo de peticiones por minuto para este usuario; 0 = ilimitado. Solo se usa como respaldo cuando el grupo no tiene rpm_limit definido.' + }, + columns: { + user: 'Usuario', + id: 'ID', + email: 'Correo electrónico', + username: 'Nombre de usuario', + notes: 'Notas', + role: 'Rol', + groups: 'Grupos', + subscriptions: 'Suscripciones', + balance: 'Saldo', + balancePlatformQuota: 'Saldo (cuota por plataforma)', + usage: 'Consumo', + usageAnthropic: 'Consumo (Claude)', + usageOpenAI: 'Consumo (OpenAI)', + usageGemini: 'Consumo (Gemini)', + usageAntigravity: 'Consumo (Antigravity)', + concurrency: 'Concurrencia', + status: 'Estado', + lastActive: 'Última actividad', + lastUsed: 'Último uso', + created: 'Creado', + actions: 'Acciones' + }, + today: 'Hoy', + total: 'Últimos 30 d', + sortBy: 'Ordenar por', + sortCurrentPageOnly: 'Ordena solo la página actual', + noSubscription: 'Sin suscripción', + publicGroupCount: '+{count} públicos', + exclusiveLabel: 'exclusivo', + publicLabel: 'público', + daysRemaining: '{days} d', + expired: 'Caducada', + disable: 'Desactivar', + enable: 'Activar', + disableUser: 'Desactivar el usuario', + enableUser: 'Activar el usuario', + viewApiKeys: 'Ver las claves API', + groups: 'Grupos', + apiKeys: 'Claves API', + userApiKeys: 'Claves API del usuario', + noApiKeys: 'Este usuario no tiene claves API', + group: 'Grupo', + none: 'Ninguno', + groupChangedSuccess: 'Grupo actualizado correctamente', + groupChangedWithGrant: 'Grupo actualizado. Al usuario se le dio acceso automáticamente a «{group}»', + groupChangeFailed: 'No se pudo actualizar el grupo', + noUsersYet: 'Todavía no hay usuarios', + createFirstUser: 'Crea tu primer usuario para empezar.', + userCreated: 'Usuario creado correctamente', + userUpdated: 'Usuario actualizado correctamente', + userDeleted: 'Usuario eliminado correctamente', + userEnabled: 'Usuario activado correctamente', + userDisabled: 'Usuario desactivado correctamente', + failedToLoad: 'No se pudieron cargar los usuarios', + failedToCreate: 'No se pudo crear el usuario', + failedToUpdate: 'No se pudo actualizar el usuario', + failedToDelete: 'No se pudo eliminar el usuario', + failedToToggle: 'No se pudo actualizar el estado del usuario', + failedToLoadApiKeys: 'No se pudieron cargar las claves API del usuario', + emailRequired: 'Introduce el correo', + concurrencyMin: 'La concurrencia debe ser al menos 1', + soraStorageQuota: 'Cuota de almacenamiento de Sora', + soraStorageQuotaHint: 'En GB, 0 significa usar la cuota por defecto del grupo o del sistema', + amountRequired: 'Introduce un importe válido', + insufficientBalance: 'Saldo insuficiente', + adjustBalance: 'Ajustar el saldo', + adjustConcurrency: 'Ajustar la concurrencia', + adjustmentAmount: 'Importe del ajuste', + adjustmentAmountHint: 'Positivo para añadir, negativo para restar', + currentConcurrency: 'Concurrencia actual', + saving: 'Guardando...', + noUsers: 'Todavía no hay usuarios', + noUsersDescription: 'Crea tu primer usuario para empezar.', + userCreatedSuccess: 'Usuario creado correctamente', + userUpdatedSuccess: 'Usuario actualizado correctamente', + userDeletedSuccess: 'Usuario eliminado correctamente', + balanceAdjustedSuccess: 'Saldo ajustado correctamente', + concurrencyAdjustedSuccess: 'Concurrencia ajustada correctamente', + failedToSave: 'No se pudo guardar el usuario', + failedToAdjust: 'Falló el ajuste', + deleteConfirm: '¿Seguro que quieres eliminar «{email}»? Esta acción no se puede deshacer.', + setAllowedGroups: 'Definir los grupos permitidos', + allowedGroupsHint: + 'Selecciona qué grupos estándar puede usar este usuario. Los grupos de suscripción se gestionan aparte.', + noStandardGroups: 'No hay grupos estándar disponibles', + allowAllGroups: 'Permitir todos los grupos', + allowAllGroupsHint: 'El usuario puede usar cualquier grupo no exclusivo', + allowedGroupsUpdated: 'Grupos permitidos actualizados correctamente', + failedToLoadGroups: 'No se pudieron cargar los grupos', + failedToUpdateAllowedGroups: 'No se pudieron actualizar los grupos permitidos', + // User Group Configuration + groupConfig: 'Configuración de grupos del usuario', + groupConfigHint: 'Configura multiplicadores de tarifa personalizados para el usuario {email} (sustituyen a los del grupo)', + exclusiveGroups: 'Grupos exclusivos', + publicGroups: 'Grupos públicos (disponibles por defecto)', + defaultRate: 'Tarifa por defecto', + customRate: 'Tarifa personalizada', + useDefaultRate: 'Usar la de por defecto', + customRatePlaceholder: 'Déjalo vacío para la de por defecto', + groupConfigUpdated: 'Configuración de grupos actualizada correctamente', + replaceGroup: 'Sustituir el grupo', + clickToReplace: 'Pulsa para sustituir', + replaceGroupTitle: 'Sustituir el grupo exclusivo', + replaceGroupHint: 'Selecciona un grupo nuevo para sustituir a «{old}». Las claves se migrarán y los permisos se actualizarán automáticamente.', + replaceGroupConfirm: 'Confirmar la sustitución', + replaceGroupSuccess: 'Grupo sustituido correctamente, se migraron {count} clave(s)', + selectNewGroup: 'Selecciona el grupo de destino', + noOtherGroups: 'No hay otros grupos exclusivos disponibles', + deposit: 'Ingresar', + withdraw: 'Retirar', + depositAmount: 'Importe a ingresar', + withdrawAmount: 'Importe a retirar', + withdrawAll: 'Todo', + currentBalance: 'Saldo actual', + depositNotesPlaceholder: + 'p. ej. regalo de bienvenida, saldo promocional, compensación, etc.', + withdrawNotesPlaceholder: + 'p. ej. reembolso por incidencia, corrección de un cargo erróneo, reembolso por cierre de cuenta, etc.', + notesOptional: 'Las notas son opcionales, pero ayudan a llevar el registro', + amountHint: 'Introduce un importe positivo', + newBalance: 'Saldo nuevo', + depositing: 'Ingresando...', + withdrawing: 'Retirando...', + confirmDeposit: 'Confirmar el ingreso', + confirmWithdraw: 'Confirmar la retirada', + depositSuccess: 'Ingreso realizado', + withdrawSuccess: 'Retirada realizada', + failedToDeposit: 'No se pudo hacer el ingreso', + failedToWithdraw: 'No se pudo hacer la retirada', + useDepositWithdrawButtons: 'Usa los botones de ingresar y retirar para ajustar el saldo', + // Balance History + balanceHistory: 'Historial de recargas', + balanceHistoryTip: 'Pulsa para abrir el historial de recargas', + columnAlwaysVisible: 'Esta columna siempre está visible', + // Per-platform usage breakdown (hover tooltip) + platformBreakdown: 'Desglose por plataforma', + platformBreakdownEmpty: 'Todavía no hay consumo por plataforma', + platformBreakdownHint: 'Pasa el cursor para ver el consumo por plataforma', + platformOther: 'Otras', + balanceHistoryTitle: 'Historial de recargas y concurrencia del usuario', + noBalanceHistory: 'No se encontraron registros para este usuario', + allTypes: 'Todos los tipos', + typeBalance: 'Saldo (canje)', + typeAffiliateBalance: 'Saldo (transferencia de comisiones)', + typeAdminBalance: 'Saldo (administrador)', + typeConcurrency: 'Concurrencia (canje)', + typeAdminConcurrency: 'Concurrencia (administrador)', + typeSubscription: 'Suscripción', + failedToLoadBalanceHistory: 'No se pudo cargar el historial de saldo', + createdAt: 'Creado', + totalRecharged: 'Total recargado', + roles: { + admin: 'Administrador', + user: 'Usuario' + }, + // Settings Dropdowns + filterSettings: 'Configuración de filtros', + columnSettings: 'Configuración de columnas', + filterValue: 'Introduce un valor', + // User Attributes + attributes: { + title: 'Atributos de usuario', + description: 'Configura campos de atributo personalizados para los usuarios', + configButton: 'Atributos', + addAttribute: 'Añadir un atributo', + editAttribute: 'Editar el atributo', + deleteAttribute: 'Eliminar el atributo', + deleteConfirm: '¿Seguro que quieres eliminar el atributo «{name}»? Se borrarán todos los valores de este atributo en todos los usuarios.', + noAttributes: 'No hay atributos personalizados', + noAttributesHint: 'Pulsa el botón de arriba para añadir atributos personalizados', + key: 'Clave del atributo', + keyHint: 'Para referenciarlo desde el código, solo letras, números y guiones bajos', + name: 'Nombre visible', + nameHint: 'Nombre que se muestra en los formularios', + type: 'Tipo de atributo', + fieldDescription: 'Descripción', + fieldDescriptionHint: 'Texto descriptivo del atributo', + placeholder: 'Texto de ayuda', + placeholderHint: 'Texto de ayuda del campo de entrada', + required: 'Obligatorio', + enabled: 'Activado', + options: 'Opciones', + optionsHint: 'Para los tipos de selección simple y múltiple', + addOption: 'Añadir una opción', + optionValue: 'Valor de la opción', + optionLabel: 'Texto visible', + validation: 'Reglas de validación', + minLength: 'Longitud mínima', + maxLength: 'Longitud máxima', + min: 'Valor mínimo', + max: 'Valor máximo', + pattern: 'Expresión regular', + patternMessage: 'Mensaje de error de validación', + types: { + text: 'Texto', + textarea: 'Área de texto', + number: 'Número', + email: 'Correo electrónico', + url: 'URL', + date: 'Fecha', + select: 'Selección', + multi_select: 'Selección múltiple' + }, + created: 'Atributo creado correctamente', + updated: 'Atributo actualizado correctamente', + deleted: 'Atributo eliminado correctamente', + reordered: 'Orden de los atributos actualizado correctamente', + failedToLoad: 'No se pudieron cargar los atributos', + failedToCreate: 'No se pudo crear el atributo', + failedToUpdate: 'No se pudo actualizar el atributo', + keyRequired: 'Introduce la clave del atributo', + nameRequired: 'Introduce el nombre visible', + optionsRequired: 'Añade al menos una opción', + failedToDelete: 'No se pudo eliminar el atributo', + failedToReorder: 'No se pudo actualizar el orden', + keyExists: 'La clave del atributo ya existe', + dragToReorder: 'Arrastra para reordenar' + }, + platformQuota: { + menuItem: 'Cuotas por plataforma', + title: 'Cuotas por plataforma', + subtitle: 'Configura los límites de consumo diario, semanal y mensual en USD para cada plataforma del usuario {email}', + columns: { + platform: 'Plataforma', + daily: 'Diario (USD)', + weekly: 'Semanal (USD)', + monthly: 'Mensual (USD, 30 días móviles)', + usage: 'Consumo actual', + }, + placeholder: 'ilimitado', + save: 'Guardar', + saving: 'Guardando...', + cancel: 'Cancelar', + clearAll: 'Borrar todo (quitar todos los límites)', + clearAllConfirm: '¿Borrar los límites diarios, semanales y mensuales de TODAS las plataformas? Todas quedarán «ilimitadas» y no hay forma de deshacerlo aquí: tendrás que volver a escribir los valores a mano antes de guardar.', + reset: { + button: 'Restablecer la ventana', + confirm: '¿Restablecer el consumo {window} de {platform} para este usuario? Se aplica de inmediato.', + success: 'Se restableció el consumo {window} de {platform}', + failed: 'Falló el restablecimiento', + }, + updateSuccess: 'Cuotas por plataforma actualizadas', + updateFailed: 'Falló el guardado', + loadFailed: 'Falló la carga', + hint: 'Vacío = sin límite para esa ventana.', + windowDaily: 'diario', + windowWeekly: 'semanal', + windowMonthly: 'mensual', + cellNotConfigured: 'Sin configurar', + cellColumnTooltip: 'Solo se muestran las plataformas con algún límite', + subscriptionWarning: 'Este usuario tiene una suscripción activa. Las cuotas por plataforma solo se aplican a las peticiones en modo saldo (estándar); las peticiones en modo suscripción no están sujetas a estos límites.', + invalidNumber: 'Los siguientes campos contienen números no válidos. Corrígelos antes de guardar: {fields}', + } + }, + + // Groups + groups: { + title: 'Gestión de grupos', + description: 'Gestiona los grupos de claves API y los multiplicadores de tarifa', + searchGroups: 'Buscar grupos...', + createGroup: 'Crear un grupo', + editGroup: 'Editar el grupo', + deleteGroup: 'Eliminar el grupo', + duplicate: 'Duplicar', + duplicating: 'Duplicando', + duplicateSuccess: 'Grupo duplicado como «{name}» y desactivado. Revisa su configuración antes de activarlo.', + duplicateFailed: 'No se pudo duplicar el grupo', + sortOrder: 'Ordenar', + columnSettings: 'Configuración de columnas', + sortOrderHint: 'Arrastra los grupos para cambiar el orden; los de arriba se muestran primero', + sortOrderUpdated: 'Orden actualizado', + failedToUpdateSortOrder: 'No se pudo actualizar el orden', + allPlatforms: 'Todas las plataformas', + allStatus: 'Todos los estados', + allGroups: 'Todos los grupos', + exclusiveFilter: 'Exclusivo', + exclusive: 'Exclusivo', + nonExclusive: 'No exclusivo', + public: 'Público', + columns: { + name: 'Nombre', + id: 'ID', + platform: 'Plataforma', + rateMultiplier: 'Multiplicador de tarifa', + rpmOverride: 'RPM personalizado', + rpmOverrideHint: 'Tope de RPM por usuario en este grupo; vacío = el del grupo; 0 = ilimitado', + rateDefault: 'por defecto', + rpmDefault: 'por defecto', + exclusive: 'Exclusivo', + type: 'Tipo', + priority: 'Prioridad', + apiKeys: 'Claves API', + accounts: 'Cuentas', + capacity: 'Capacidad', + usage: 'Consumo', + status: 'Estado', + actions: 'Acciones', + billingType: 'Tipo de facturación', + userName: 'Nombre de usuario', + userEmail: 'Correo electrónico', + userNotes: 'Notas', + userStatus: 'Estado' + }, + usageToday: 'Hoy', + usageTotal: 'Total', + accountsAvailable: 'Disp.:', + accountsRateLimited: 'Limitadas:', + accountsTotal: 'Total:', + accountsUnit: '', + rateAndAccounts: 'tarifa {rate}x · {count} cuentas', + accountsCount: '{count} cuentas', + rateLabel: 'tarifa', + accountFilters: { + title: 'Filtros de selección de cuentas', + oauthOnly: 'Permitir solo cuentas OAuth', + oauthOnlyEnabled: 'Activado: se excluirán las cuentas de tipo clave API', + privacySetOnly: 'Permitir solo cuentas con la protección de privacidad configurada', + privacySetOnlyEnabled: 'Activado: se excluirán las cuentas sin privacidad configurada', + disabled: 'Desactivado' + }, + form: { + name: 'Nombre', + description: 'Descripción', + platform: 'Plataforma', + rateMultiplier: 'Multiplicador de tarifa', + status: 'Estado', + exclusive: 'Grupo exclusivo', + nameLabel: 'Nombre del grupo', + namePlaceholder: 'Introduce el nombre del grupo', + descriptionLabel: 'Descripción', + descriptionPlaceholder: 'Introduce una descripción (opcional)', + rateMultiplierLabel: 'Multiplicador de tarifa', + rateMultiplierHint: '1.0 = tarifa estándar, 0.5 = mitad de precio, 2.0 = el doble', + rpmLimit: 'Peticiones por minuto (RPM)', + rpmLimitPlaceholder: '0 = ilimitado', + rpmLimitHint: 'Máximo de peticiones por minuto de cada usuario en este grupo; 0 = ilimitado. Una vez definido, manda sobre el límite por usuario en este grupo (sustituye al rpm_limit del usuario).', + maxReasoningEffort: 'Esfuerzo de razonamiento máximo', + maxReasoningEffortUnlimited: 'Ilimitado (según la petición)', + maxReasoningEffortHint: 'Solo limita las peticiones que piden explícitamente esfuerzo de razonamiento a OpenAI. En los grupos compuestos se aplica únicamente a las peticiones que acaban en OpenAI. Los valores más altos se recortan; si no se indica esfuerzo, se deja sin indicar. Este tope tiene prioridad sobre las conversiones de esfuerzo.', + reasoningEffortMappings: 'Conversiones de esfuerzo de razonamiento', + addReasoningEffortMapping: 'Añadir una conversión', + removeReasoningEffortMapping: 'Quitar la conversión', + reasoningEffortFrom: 'Valor de la petición', + reasoningEffortTo: 'Valor reenviado', + reasoningEffortFromPlaceholder: 'Selecciona A', + reasoningEffortToPlaceholder: 'Selecciona B', + fromRequired: 'Selecciona el valor A de la petición', + toRequired: 'Selecciona el valor B reenviado', + unsupportedFrom: 'Esta plataforma no admite el valor de la petición', + unsupportedTo: 'Esta plataforma no admite el valor reenviado', + duplicateFrom: 'El valor A de la petición debe ser único', + exclusiveLabel: 'Grupo exclusivo', + exclusiveHint: 'Grupo exclusivo, se puede asignar a mano a los usuarios', + platformLabel: 'Restricción de plataforma', + platformPlaceholder: 'Selecciona la plataforma (déjalo vacío para no restringir)', + accountsLabel: 'Cuentas designadas', + accountsPlaceholder: 'Selecciona cuentas (déjalo vacío para no restringir)', + priorityLabel: 'Prioridad', + priorityHint: 'Un valor más bajo significa más prioridad, se usa para repartir las cuentas', + statusLabel: 'Estado' + }, + exclusiveObj: { + yes: 'Sí', + no: 'No' + }, + enterGroupName: 'Introduce el nombre del grupo', + optionalDescription: 'Descripción opcional', + platformHint: 'Selecciona la plataforma a la que se asocia este grupo', + platformNotEditable: 'La plataforma no se puede cambiar después de crearlo', + saving: 'Guardando...', + noGroups: 'Todavía no hay grupos', + noGroupsDescription: 'Crea un grupo para gestionar mejor las claves API y las tarifas.', + groupCreatedSuccess: 'Grupo creado correctamente', + groupUpdatedSuccess: 'Grupo actualizado correctamente', + groupDeletedSuccess: 'Grupo eliminado correctamente', + rateMultiplierHint: 'Multiplicador del coste de este grupo (p. ej. 1.5 = 150 % del coste base)', + exclusiveHint: 'Grupo exclusivo, se asigna a mano a usuarios concretos', + exclusiveTooltip: { + title: '¿Qué es un grupo exclusivo?', + description: 'Si está activado, los usuarios no verán este grupo al crear claves API. Solo podrán usarlo cuando un administrador les asigne el grupo a mano.', + example: 'Caso de uso:', + exampleContent: 'La tarifa del grupo público es 0.8. Crea un grupo exclusivo con tarifa 0.7 y asígnalo a mano a tus usuarios VIP para darles un precio mejor.' + }, + noGroupsYet: 'Todavía no hay grupos', + createFirstGroup: 'Crea tu primer grupo para organizar las claves API.', + creating: 'Creando...', + updating: 'Actualizando...', + limitDay: 'd', + limitWeek: 'sem', + limitMonth: 'mes', + groupCreated: 'Grupo creado correctamente', + groupUpdated: 'Grupo actualizado correctamente', + groupDeleted: 'Grupo eliminado correctamente', + failedToLoad: 'No se pudieron cargar los grupos', + failedToCreate: 'No se pudo crear el grupo', + failedToUpdate: 'No se pudo actualizar el grupo', + failedToSave: 'No se pudo guardar el grupo', + failedToDelete: 'No se pudo eliminar el grupo', + nameRequired: 'Introduce el nombre del grupo', + rateMultipliers: 'Multiplicadores de tarifa', + rateMultipliersTitle: 'Multiplicadores de tarifa del grupo', + addUserRate: 'Añadir un multiplicador de tarifa por usuario', + rpmOverrides: 'RPM personalizados', + rpmOverridesTitle: 'RPM personalizados del grupo', + addUserRpm: 'Añadir un RPM personalizado por usuario', + noRpmOverrides: 'Todavía no hay usuarios con un RPM personalizado', + rpmSaved: 'RPM personalizados guardados', + groupRpmDefault: 'RPM por defecto del grupo', + searchUserPlaceholder: 'Buscar por correo del usuario...', + noRateMultipliers: 'No hay multiplicadores de tarifa por usuario configurados', + rateUpdated: 'Multiplicador de tarifa actualizado', + rateDeleted: 'Multiplicador de tarifa eliminado', + rateAdded: 'Multiplicador de tarifa añadido', + clearAll: 'Borrar todo', + confirmClearAll: '¿Seguro que quieres borrar todos los multiplicadores de tarifa de este grupo? No se puede deshacer.', + rateCleared: 'Se borraron todos los multiplicadores de tarifa', + batchAdjust: 'Ajustar tarifas en bloque', + multiplierFactor: 'Factor', + applyMultiplier: 'Aplicar', + rateAdjusted: 'Tarifas ajustadas correctamente', + rateSaved: 'Multiplicadores de tarifa guardados', + finalRate: 'Tarifa final', + unsavedChanges: 'Cambios sin guardar', + revertChanges: 'Deshacer', + userInfo: 'Información del usuario', + platforms: { + all: 'Todas las plataformas', + anthropic: 'Anthropic', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity', + grok: 'Grok', + composite: 'Compuesta', + }, + deleteConfirm: + '¿Seguro que quieres eliminar «{name}»? Todas las claves API asociadas dejarán de pertenecer a ningún grupo.', + deleteConfirmSubscription: + '¿Seguro que quieres eliminar el grupo de suscripción «{name}»? Esto invalidará todas las claves API vinculadas a esta suscripción y borrará todos los registros de suscripción relacionados. Esta acción no se puede deshacer.', + subscription: { + title: 'Ajustes de la suscripción', + type: 'Tipo de facturación', + typeHint: + 'La facturación estándar descuenta del saldo del usuario. El modo suscripción usa límites de cuota en su lugar.', + typeNotEditable: 'El tipo de facturación no se puede cambiar después de crear el grupo.', + standard: 'Estándar (saldo)', + subscription: 'Suscripción (cuota)', + dailyLimit: 'Límite diario (USD)', + weeklyLimit: 'Límite semanal (USD)', + monthlyLimit: 'Límite mensual (USD)', + defaultValidityDays: 'Validez por defecto (días)', + validityHint: 'Número de días que dura la suscripción cuando se asigna a un usuario', + noLimit: 'Sin límite' + }, + imagePricing: { + title: 'Precios de generación de imágenes', + description: 'Configura el acceso a la generación de imágenes y los precios base. Déjalo vacío para usar los precios por defecto.', + allowImageGeneration: 'Permitir la generación de imágenes en este grupo', + allowBatchImageGeneration: 'Permitir la generación de imágenes por lotes en este grupo', + independentMultiplier: 'Usar un multiplicador de imagen independiente', + imageMultiplier: 'Multiplicador de imagen', + batchDiscountMultiplier: 'Descuento por lotes', + batchHoldMultiplier: 'Proporción del precio retenido en los lotes', + batchSectionHint: 'Los ajustes de imagen por lotes solo se aplican a los trabajos por lotes: la liquidación aplica el descuento por lotes y la retención previa es el precio normal de imagen × la proporción de retención. Las imágenes de referencia también generan consumo de tokens de entrada en el proveedor, así que se recomienda un descuento por lotes superior a 0,5.', + batchDisabledHint: 'Activa la generación de imágenes en este grupo antes de activar la generación por lotes.', + batchGeminiOnlyHint: 'Por ahora la generación de imágenes por lotes solo está disponible en los grupos de Gemini.', + modeHint: 'Por defecto, la facturación de imagen usa el precio de imagen × el multiplicador efectivo del grupo. El modo independiente usa el precio de imagen × el multiplicador de imagen.', + finalPricePreview: 'Vista previa del precio final por imagen', + notConfigured: 'Sin configurar' + }, + videoPricing: { + title: 'Precios de generación de vídeo', + description: + 'Configura los precios de generación de vídeo de Grok en USD por segundo de vídeo generado. Déjalo vacío para usar las tarifas por segundo por defecto (grok-imagine-video: 0,05 $/s a 480p, 0,07 $/s a 720p; video-1.5: 0,08 $/s a 480p, 0,14 $/s a 720p, 0,25 $/s a 1080p).', + modelOverridesTitle: 'Precios de vídeo personalizados por modelo', + modelOverridesDescription: 'Cada celda rellenada sustituye al precio plano por resolución de esa familia de modelos. Las versiones preliminares y los alias antiguos de video-1.5 usan la misma familia; las celdas vacías usan el precio plano por resolución.', + independentMultiplier: 'Usar un multiplicador de vídeo independiente', + videoMultiplier: 'Multiplicador de vídeo', + modeHint: + 'Los vídeos se facturan por segundo: precio por segundo × duración (de 1 a 15 s, 8 s por defecto). Por defecto se aplica el multiplicador efectivo del grupo; el modo independiente usa el multiplicador de vídeo.', + finalPricePreview: 'Vista previa del precio final por segundo', + notConfigured: 'Sin configurar' + }, + explicitPricing: { + title: 'Precios de búsqueda y voz de Grok', + description: 'Precios opcionales por grupo para web_search (por cada 1.000 llamadas) y para voz en tiempo real, TTS y STT (USD). Déjalo vacío si no los usas.', + searchPricePer1k: 'Precio de búsqueda por 1.000 llamadas (USD)', + pricePlaceholder: 'opcional' + }, + modelPricing: { + title: 'Precios por modelo del grupo', + description: 'Sustituyen a los precios del canal y a los integrados para los modelos que coincidan. Los tramos de contexto largo vienen de los valores oficiales: no introduzcas tramos personalizados. Para audio usa tramos por petición como realtime, tts y stt.', + longContext: 'Activar los precios por tramo de contexto largo', + longContextHint: 'Si se marca, se aplican los tramos oficiales de contexto largo. Si no, los modelos por token se quedan con la tarifa base del primer tramo.', + add: 'Añadir un precio de modelo' + }, + voicePricing: { + title: 'Precios de voz de Grok', + description: 'Precios opcionales por grupo para voz en tiempo real, TTS y STT (USD). Déjalo vacío para dejarlos sin precio.', + audioRealtimePerMin: 'Precio de tiempo real por minuto (USD)', + audioTtsPerMillionChars: 'Precio de TTS por millón de caracteres (USD)', + audioSttPerHour: 'Precio de STT por hora (USD)', + pricePlaceholder: 'opcional' + }, + webSearchPricing: { + title: 'Precios de búsqueda web de Codex', + pricePerCall: 'Precio por llamada de búsqueda (USD)', + pricePerCallHint: + 'Déjalo vacío para usar los 0,01 $ por llamada por defecto (precio oficial: 10 $ por cada 1.000 llamadas); 0 significa gratis. El multiplicador de tarifa del grupo se aplica encima.', + finalPricePreview: 'Precio por llamada con el multiplicador actual: {price}' + }, + peakRate: { + enable: 'Activar el multiplicador de horas punta', + peakStart: 'Inicio de las horas punta', + peakEnd: 'Fin de las horas punta', + peakMultiplier: 'Multiplicador de horas punta', + multiplierHint: 'Se aplica al multiplicador de facturación por token; los tokens de imagen facturados por token también se ven afectados. 0 significa que las peticiones por token en horas punta se facturan a 0x.' + }, + profitControl: { + enable: 'Activar el control de rentabilidad', + enabledHint: 'La planificación solo admite cuentas cuyo multiplicador sea ≤ el multiplicador efectivo del cliente × (1 − margen mínimo − colchón de seguridad). Los multiplicadores de cuenta se pueden mantener a mano o sincronizar desde las sondas; el orden, la persistencia de sesión y los cortacircuitos siguen funcionando entre las cuentas que cumplan. La planificación de imagen y vídeo todavía no está cubierta.', + disabledHint: 'Si está desactivado, la planificación no filtra por rentabilidad: se pueden elegir cuentas cuyo multiplicador supere al del cliente, lo que puede generar peticiones con pérdidas.', + minMargin: 'Margen bruto mínimo (%)', + minMarginHint: 'Se introduce en porcentaje, p. ej. 30 significa 30 %; internamente se guarda como decimal', + safetyBuffer: 'Colchón de seguridad (%)', + safetyBufferHint: 'Se suma al margen mínimo y se descuenta del multiplicador del cliente; por defecto es 0', + marginRangeError: 'El margen bruto mínimo debe estar entre 0 y 99,99', + bufferRangeError: 'El colchón de seguridad debe estar entre 0 y 99,99', + sumTooHigh: 'El margen bruto mínimo más el colchón de seguridad debe ser inferior al 100 %, si no se excluirían todas las cuentas' + }, + modelsList: { + title: 'Lista de modelos personalizada para /v1/models', + hint: 'Solo cambia la respuesta de /v1/models. Las llamadas a modelos de la lista blanca y el reparto de cuentas no cambian.', + loading: 'Cargando la lista de modelos...', + empty: 'No hay modelos que mostrar', + selectedSummary: 'Seleccionados {selected} / {total}', + selectAll: 'Seleccionar todos', + invertSelection: 'Invertir' + }, + compositeRoutes: { + action: 'Rutas', + title: 'Rutas compuestas', + titleWithGroup: 'Rutas compuestas: {name}', + routes: 'Rutas guardadas', + empty: 'No hay rutas compuestas configuradas', + publicModel: 'Modelo público', + target: 'Destino', + scope: 'Alcance', + priority: 'Prioridad', + addRoute: 'Añadir una ruta', + editRoute: 'Editar la ruta', + matchType: 'Coincidencia', + endpoint: 'Endpoint', + targetPlatform: 'Plataforma de destino', + upstreamModel: 'Modelo del proveedor', + upstreamModelHint: 'Déjalo vacío para pasar el modelo solicitado tal cual: con coincidencia por prefijo, cada modelo que coincida se reenvía literalmente (p. ej. deepseek-v4-flash y deepseek-v4-pro se reenvían tal cual); si pones un valor, todas las peticiones que coincidan se reenvían a ese modelo fijo.', + notes: 'Notas', + enabled: 'Activada', + preview: 'Vista previa', + matched: 'Coincide', + notMatched: 'No coincide', + publicModelRequired: 'El modelo público es obligatorio', + routeCreated: 'Ruta compuesta creada', + routeUpdated: 'Ruta compuesta actualizada', + routeDeleted: 'Ruta compuesta eliminada', + failedToLoad: 'No se pudieron cargar las rutas compuestas', + failedToSave: 'No se pudo guardar la ruta compuesta', + failedToDelete: 'No se pudo eliminar la ruta compuesta', + failedToPreview: 'No se pudo previsualizar la ruta compuesta', + deleteConfirm: '¿Eliminar esta ruta compuesta?', + endpoints: { + any: 'Cualquiera', + messages: 'Messages', + countTokens: 'Count Tokens', + responses: 'Responses', + chatCompletions: 'Chat Completions', + embeddings: 'Embeddings', + images: 'Images', + gemini: 'Gemini nativo' + }, + match: { + exact: 'Exacta', + prefix: 'Por prefijo' + }, + sources: { + route: 'Ruta', + detector: 'Detector' + } + }, + claudeCode: { + title: 'Restricción de cliente Claude Code', + tooltip: 'Si se activa, este grupo solo admite clientes oficiales de Claude Code. Las peticiones que no vengan de Claude Code se rechazarán o pasarán al grupo indicado.', + enabled: 'Solo Claude Code', + disabled: 'Permitir todos los clientes', + fallbackGroup: 'Grupo alternativo', + fallbackHint: 'Las peticiones que no vengan de Claude Code usarán este grupo. Déjalo vacío para rechazarlas directamente.', + noFallback: 'Sin alternativa (rechazar)' + }, + openaiMessages: { + title: 'Despacho de Messages en OpenAI', + allowDispatch: 'Permitir el despacho por /v1/messages', + allowDispatchHint: 'Si se activa, las claves API de este grupo de OpenAI pueden despachar peticiones a través del endpoint /v1/messages', + familyMappingTitle: 'Correspondencia por familia', + familyMappingHint: 'Las peticiones que coincidan con las familias Opus, Sonnet o Haiku usarán preferentemente el modelo de destino configurado aquí.', + opusModel: 'Modelo de destino para Opus', + opusModelPlaceholder: 'p. ej. gpt-5.4', + sonnetModel: 'Modelo de destino para Sonnet', + sonnetModelPlaceholder: 'p. ej. gpt-5.3-codex', + haikuModel: 'Modelo de destino para Haiku', + haikuModelPlaceholder: 'p. ej. gpt-5.4-mini', + exactMappingTitle: 'Sustituciones exactas de modelo', + exactMappingHint: 'Las sustituciones exactas tienen prioridad sobre la correspondencia por familia y permiten enviar un modelo de Claude concreto a otro modelo de destino.', + noExactMappings: 'Todavía no hay sustituciones exactas de modelo', + addExactMapping: 'Añadir una sustitución exacta', + claudeModel: 'Modelo de Claude', + claudeModelPlaceholder: 'p. ej. claude-sonnet-4-5-20250929', + targetModel: 'Modelo de destino', + targetModelPlaceholder: 'p. ej. gpt-5.4', + removeExactMapping: 'Quitar la sustitución exacta' + }, + openaiLive: { + title: 'OpenAI Live', + allow: 'Permitir el acceso a Live', + hint: 'Si se activa, las claves API de este grupo de OpenAI pueden crear y controlar sesiones de voz Live. Está desactivado por defecto. El servidor de Sub2API debe funcionar en macOS con Apple Silicon y tener instalada la app oficial de ChatGPT; las plataformas de los clientes no tienen restricción.', + unsupportedTitle: 'Este servidor no admite Live', + unsupportedMessage: 'Este servidor de Sub2API no puede generar la atestación necesaria para Live. Live no funcionará aunque lo actives. ¿Continuar de todos modos?', + enableAnyway: 'Activar de todos modos' + }, + invalidRequestFallback: { + title: 'Grupo alternativo para peticiones no válidas', + hint: 'Solo se activa cuando el proveedor devuelve explícitamente que el prompt es demasiado largo. Déjalo vacío para desactivar la alternativa.', + noFallback: 'Sin alternativa' + }, + copyAccounts: { + title: 'Copiar cuentas de otros grupos', + tooltip: 'Selecciona uno o varios grupos de la misma plataforma. Al crearlo, todas las cuentas de esos grupos se vincularán automáticamente al grupo nuevo (sin duplicados).', + tooltipEdit: 'Selecciona uno o varios grupos de la misma plataforma. Al guardar, las cuentas actuales del grupo se sustituirán por las de esos grupos (sin duplicados).', + selectPlaceholder: 'Selecciona los grupos de los que copiar cuentas...', + hint: 'Se pueden seleccionar varios grupos; las cuentas se dejarán sin duplicados', + hintEdit: '⚠️ Aviso: esto sustituirá todas las vinculaciones de cuentas existentes' + }, + modelRouting: { + title: 'Enrutado por modelo', + tooltip: 'Configura que las peticiones de ciertos modelos vayan a cuentas concretas. Admite comodines, p. ej. claude-opus-* coincide con todos los modelos opus.', + enabled: 'Activado', + disabled: 'Desactivado', + disabledHint: 'Las reglas de enrutado solo se aplican cuando está activado', + addRule: 'Añadir una regla de enrutado', + modelPattern: 'Patrón de modelo', + modelPatternPlaceholder: 'claude-opus-*', + modelPatternHint: 'Admite el comodín *, p. ej. claude-opus-* coincide con todos los modelos opus', + accounts: 'Cuentas prioritarias', + selectAccounts: 'Selecciona cuentas', + noAccounts: 'No hay cuentas en este grupo', + loadingAccounts: 'Cargando las cuentas...', + removeRule: 'Quitar la regla', + noRules: 'No hay reglas de enrutado', + noRulesHint: 'Añade reglas de enrutado para dirigir las peticiones de ciertos modelos a cuentas concretas', + searchAccountPlaceholder: 'Buscar cuentas...', + accountsHint: 'Selecciona las cuentas que tendrán prioridad para este patrón de modelo' + }, + mcpXml: { + title: 'Inyección del protocolo XML de MCP', + tooltip: 'Si se activa y la petición incluye herramientas MCP, se inyectará en el prompt del sistema un protocolo de llamada en formato XML. Desactívalo para evitar interferencias con ciertos clientes.', + enabled: 'Activado', + disabled: 'Desactivado' + }, + claudeMaxSimulation: { + title: 'Simulación de consumo de Claude Max', + tooltip: + 'Si se activa, en los modelos de Claude sin consumo de escritura de caché del proveedor, el sistema convierte los tokens de forma determinista en una entrada pequeña más una creación de caché de 1 h, manteniendo igual el total de tokens.', + enabled: 'Activada (simular caché de 1 h)', + disabled: 'Desactivada', + hint: 'Solo se ajustan las categorías de tokens en los registros de facturación. No se guarda ningún estado de conversión por petición.' + }, + supportedScopes: { + title: 'Familias de modelos admitidas', + tooltip: 'Selecciona las familias de modelos que admite este grupo. Las que no marques no se enrutarán a este grupo.', + claude: 'Claude', + geminiText: 'Gemini Texto', + geminiImage: 'Gemini Imagen', + hint: 'Selecciona al menos una familia de modelos' + } + }, + + // Available Channels (aggregated read-only view) +} diff --git a/frontend/src/i18n/locales/es/admin/promptAudit.ts b/frontend/src/i18n/locales/es/admin/promptAudit.ts new file mode 100644 index 00000000000..d10af8c967d --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/promptAudit.ts @@ -0,0 +1,100 @@ +export default { + promptAudit: { + title: 'Auditoría de prompts', + description: 'Revisa la entrada del usuario de forma asíncrona o bloquéala de forma síncrona mediante nodos Qwen3Guard compatibles con OpenAI. Los prompts completos se guardan junto con los eventos para que los revise un administrador.', + configVersion: 'Versión de configuración v{version}', + tabs: { config: 'Configuración', events: 'Eventos' }, + actions: { refresh: 'Actualizar el estado', retry: 'Reintentar', Allow: 'Permitir', Warn: 'Advertir', Block: 'Bloquear' }, + common: { actions: 'Acciones', never: 'Nunca' }, + mode: { off: 'Desactivado', async_audit: 'Solo auditoría asíncrona', blocking: 'Auditoría síncrona con bloqueo' }, + status: { disabled: 'Desactivado', running: 'En ejecución', degraded: 'Degradado', error: 'Error', healthy: 'Saludable', failed: 'Fallido', stale: 'Latido obsoleto' }, + decisions: { pass: 'Aprobado', flag: 'Marcado', critical: 'Crítico' }, + riskLevels: { low: 'Bajo', medium: 'Medio', high: 'Alto', critical: 'Crítico' }, + scanners: { + violent: 'Violencia', + non_violent_illegal_acts: 'Actos ilegales sin violencia', + sexual_content_or_sexual_acts: 'Contenido o actos sexuales', + pii: 'Datos personales', + suicide_and_self_harm: 'Suicidio y autolesiones', + unethical_acts: 'Actos poco éticos', + politically_sensitive_topics: 'Temas políticamente sensibles', + copyright_violation: 'Violación de derechos de autor', + jailbreak: 'Jailbreak', + }, + scannerDescriptions: { + violent: 'Violencia o amenazas de violencia', + non_violent_illegal_acts: 'Actividad ilegal sin violencia', + sexual_content_or_sexual_acts: 'Contenido o actos sexuales', + pii: 'Información de identificación personal', + suicide_and_self_harm: 'Suicidio o autolesiones', + unethical_acts: 'Comportamiento poco ético', + politically_sensitive_topics: 'Temas políticamente sensibles', + copyright_violation: 'Infracción de derechos de autor', + jailbreak: 'Intento de inyección de prompt o jailbreak', + }, + runtime: { + title: 'Resumen de ejecución', + description: 'Muestra la configuración activa ahora mismo en el servidor. Los cambios del borrador sin guardar no afectan a estos valores.', + process: 'Estado del proceso', mode: 'Modo efectivo', version: 'Versión activa / esperada', workers: 'Procesos activos / totales', + queue: 'Trabajos activos / capacidad', dependencies: 'Dependencias', guardMetrics: 'Métricas del Guard síncrono', latest: 'Último procesamiento y error', + queueBreakdown: 'en cola {queued} · procesando {processing} · reintento {retry} · terminados {done} · fallidos {failed}', + deliveryTotals: 'Total encolados {enqueued} · descartados {dropped} · procesados {processed} · fallidos {failed}', + }, + metrics: { total: 'Total', allowed: 'Permitidos', flagged: 'Marcados', blocked: 'Bloqueados', unavailable: 'No disponible', timeouts: 'Tiempos agotados', failovers: 'Conmutaciones' }, + pool: { + title: 'Grupo de auditoría', description: 'Los nodos compatibles con OpenAI que estén activados se prueban en orden. Las pruebas se lanzan desde la red del servidor.', + add: 'Añadir nodo', edit: 'Editar nodo', empty: 'No hay nodos de auditoría configurados.', node: 'Nodo', model: 'Modelo', limits: 'Tiempo de espera / límite por bloque', credential: 'Credencial y prueba', + configured: 'Clave API configurada', missing: 'Falta la clave API', invalid: 'La clave API no se puede descifrar; vuelve a introducirla', probe: 'Probar la conexión', probing: 'Probando…', + probeProgress: 'Configuración validada ✓ · petición enviada · esperando la respuesta del servicio…', probeResult: 'Configuración ✓ · petición ✓ · HTTP {http} · {status} · {latency} ms', + name: 'Nombre del nodo', id: 'ID estable del nodo', baseUrl: 'URL base', apiKey: 'Clave API', keepSecret: 'Déjalo vacío para conservar la clave API guardada', reenterSecret: 'La clave API guardada no se puede descifrar (cambió la clave de cifrado); introduce una nueva', + secretHint: 'El texto sin cifrar solo existe en este editor y se borra en cuanto se guarda correctamente.', clearSecret: 'Borrar explícitamente la clave API guardada', timeout: 'Tiempo de espera total (ms)', inputLimit: 'Caracteres Unicode por bloque', + toggleNode: 'Activar o desactivar el nodo {name}', deleteConfirm: '¿Quitar «{name}» del borrador? Se aplicará al guardar.', + }, + policy: { + title: 'Política de auditoría', description: 'Configura el alcance por grupos, las nueve categorías de riesgo de entrada, los procesos y los límites de la cola.', scope: 'Alcance', allGroups: 'Todos los grupos', selectedGroups: 'Grupos seleccionados', + searchGroups: 'Buscar grupos', noGroups: 'No hay grupos que coincidan', missingGroups: 'IDs configurados de grupos que ya no existen', selectedCount: '{count} grupos seleccionados', + scanners: 'Categorías de riesgo de entrada de Qwen3Guard', workerCount: 'Número de procesos', queueCapacity: 'Capacidad de la cola persistente', strategy: 'Estrategia de nodos', strategyHint: 'Probar los nodos en el orden configurado y conmutar cuando esté permitido.', + }, + saveBar: { enabled: 'Activar la auditoría de prompts', blocking: 'Bloqueo síncrono', blockingLatestTurnOnly: 'Solo la última entrada y la salida anterior', storePass: 'Guardar los eventos seguros', dirty: 'Cambios sin guardar', synced: 'Configuración sincronizada' }, + blockingConfirm: { + title: '¿Activar el bloqueo síncrono?', + message: 'Las peticiones afectadas esperarán al Guard antes de seleccionar la cuenta, facturar o acceder al proveedor. El bloqueo, un Guard no disponible y las respuestas no válidas impiden el acceso al proveedor.', + confirm: 'Lo entiendo, actívalo', + }, + events: { + title: 'Eventos de auditoría', description: 'Revisa los eventos por identidad, ruta, riesgo, hash y hora; la vista de detalle muestra el prompt completo.', decision: 'Decisión', risk: 'Nivel de riesgo', endpoint: 'Endpoint', groupId: 'ID del grupo', userId: 'ID del usuario', apiKeyId: 'ID de la clave API', keyword: 'Palabra clave', + startAt: 'Hora de inicio', endAt: 'Hora de fin', deleteSelected: 'Eliminar los seleccionados ({count})', deleteByFilter: 'Eliminar por filtro', + filterDeleteDialogTitle: 'Eliminar eventos de auditoría por filtro', filterDeleteDialogDesc: 'Elige el rango de fechas y los criterios de riesgo, y elimina directamente. La eliminación es permanente. Genera primero una vista previa si quieres ver cuántos coinciden.', + filterTimeRange: 'Rango de fechas a eliminar', filterTimeRangeHint: 'Elimina los eventos creados antes del corte seleccionado. Los eventos creados después de la vista previa no se ven afectados.', + timePresets: { '1d': 'Más de 1 día', '7d': 'Más de 7 días', '30d': 'Más de 30 días', '90d': 'Más de 90 días', all: 'Todo el histórico', custom: 'Rango personalizado' }, + customRangeInvalid: 'Un rango personalizado necesita una hora de inicio y de fin válidas, con el inicio antes del fin.', + moreConditions: 'Más condiciones (endpoint / palabra clave / grupo / usuario)', + filterDeletePreviewAction: 'Generar vista previa de la eliminación', filterDeletePreviewing: 'Generando la vista previa…', filterDeleteNeedPreview: 'Puedes eliminar directamente o generar antes una vista previa para ver cuántos coinciden.', + filterDeleteConfirmInvalidRange: 'Selecciona primero un rango de fechas válido (un rango personalizado necesita que el inicio sea anterior al fin).', filterDeleteConfirmNoMatches: 'Los filtros actuales no coinciden con ningún evento, así que no hay nada que eliminar.', + selectAll: 'Seleccionar todos los eventos de esta página', selectEvent: 'Seleccionar el evento {id}', time: 'Hora', identity: 'Usuario / correo / clave API', user: 'Nombre de usuario', email: 'Correo del usuario', apiKey: 'Nombre de la clave API', group: 'Grupo', route: 'Endpoint / modelo', result: 'Decisión / riesgo', preview: 'Vista previa censurada', empty: 'No hay eventos que coincidan.', + passEventsDisabled: '«Guardar los eventos seguros» está desactivado. Las peticiones seguras se siguen auditando pero no aparecen en esta lista; los eventos de riesgo Marcado y Crítico sí se guardan.', openConfiguration: 'Abrir la configuración', + detailTitle: 'Detalles del evento de auditoría de prompt', tabs: { summary: 'Resumen de la auditoría', risks: 'Riesgos concretos', technical: 'Detalles técnicos' }, + promptFull: 'Prompt completo (sin censurar)', + promptFullHint: 'El prompt completo se guarda con este evento únicamente para que lo revise un administrador. Trátalo como dato sensible y no lo compartas.', + guardReturn: 'Respuesta de la auditoría del modelo', + guardReturnHint: 'Resultado normalizado del Guard (decisión, categorías, puntuaciones y evidencia censurada). Los cuerpos de respuesta en bruto no se guardan.', + riskSummaries: 'Resúmenes de riesgo', + evidence: 'Evidencia censurada', + score: 'Puntuación', + categories: 'Categorías', model: 'Modelo', stage: 'Etapa de la petición', noRisks: 'Este evento no tiene resúmenes de riesgo derivados.', + requestId: 'ID de la petición', promptHash: 'SHA-256 del prompt', + technical: { + scanner: 'Detector', policy: 'Política', guardEndpoint: 'Endpoint del Guard', config: 'Configuración', + chunks: 'Bloques', latency: 'Latencia', protocol: 'Protocolo', + }, + deleteConfirmTitle: '¿Eliminar los eventos de auditoría?', deleteConfirmMessage: 'Esto elimina de forma permanente {count} eventos y los trabajos huérfanos que corresponda.', filterDeleteCount: 'La instantánea del servidor coincide con {count} eventos.', snapshotMax: 'ID máximo de evento en la instantánea', expiresAt: 'El token de confirmación caduca', filterDeleteWarning: 'Solo se eliminan los eventos iguales o anteriores a la marca de la vista previa. Los más nuevos se conservan. Cualquier cambio en los filtros exige una vista previa nueva.', confirmFilterDelete: 'Eliminar de forma permanente', + }, + messages: { saved: 'Configuración de la auditoría de prompts guardada; se borró la clave API en texto plano.', probeSucceeded: 'El nodo de auditoría es accesible.', deleted: 'Se eliminaron {count} eventos de auditoría.' }, + errors: { + loadConfig: 'No se pudo cargar la configuración de la auditoría de prompts.', loadRuntime: 'No se pudo cargar el estado de ejecución de la auditoría de prompts.', loadGroups: 'No se pudieron cargar los grupos.', loadEvents: 'No se pudieron cargar los eventos de auditoría.', loadDetail: 'No se pudieron cargar los detalles del evento.', saveConfig: 'No se pudo guardar la configuración.', probe: 'Falló la prueba del nodo.', delete: 'No se pudieron eliminar los eventos.', previewDelete: 'No se pudo crear la vista previa de la eliminación. Revisa el rango de fechas.', deleteConfirmation: 'La confirmación de eliminación no es válida o caducó. Genera la vista previa de nuevo.', + prompt_audit_config_conflict: 'Otro administrador actualizó esta configuración. Recarga la versión del servidor antes de decidir cómo fusionar tu borrador.', + prompt_audit_encryption_key_required: 'No hay una clave de cifrado fija configurada, así que las claves API de los nodos de auditoría se perderían al reiniciar. Define la variable de entorno TOTP_ENCRYPTION_KEY y reinicia el servicio primero.', + prompt_guard_requires_audit_enabled: 'Activa la auditoría de prompts antes del bloqueo síncrono.', prompt_audit_invalid_endpoint: 'La configuración del nodo de auditoría no es válida.', prompt_audit_endpoint_required: 'Activa al menos un nodo de auditoría antes de activar la auditoría de prompts.', prompt_audit_groups_required: 'Selecciona al menos un grupo en el modo de grupos seleccionados.', prompt_audit_scanners_required: 'Activa al menos una categoría de riesgo.', + }, + }, +} diff --git a/frontend/src/i18n/locales/es/admin/resources.ts b/frontend/src/i18n/locales/es/admin/resources.ts new file mode 100644 index 00000000000..c57edf5bbca --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/resources.ts @@ -0,0 +1,604 @@ +export default { + scheduledTests: { + title: 'Pruebas programadas', + addPlan: 'Añadir un plan', + editPlan: 'Editar el plan', + deletePlan: 'Eliminar el plan', + model: 'Modelo', + cronExpression: 'Expresión cron', + enabled: 'Activado', + lastRun: 'Última ejecución', + nextRun: 'Próxima ejecución', + maxResults: 'Resultados máximos', + noPlans: 'No hay planes de pruebas programadas', + confirmDelete: '¿Seguro que quieres eliminar este plan?', + createSuccess: 'Plan creado correctamente', + updateSuccess: 'Plan actualizado correctamente', + deleteSuccess: 'Plan eliminado correctamente', + results: 'Resultados de las pruebas', + noResults: 'Todavía no hay resultados de pruebas', + responseText: 'Respuesta', + errorMessage: 'Error', + success: 'Correcto', + failed: 'Fallido', + running: 'En ejecución', + schedule: 'Programación', + cronHelp: 'Expresión cron estándar de 5 campos (p. ej. */30 * * * *)', + cronTooltipTitle: 'Ejemplos de expresiones cron:', + cronTooltipMeaning: 'Define cuándo se ejecuta la prueba automáticamente. Los 5 campos son: minuto, hora, día, mes y día de la semana.', + cronTooltipExampleEvery30Min: '*/30 * * * *: se ejecuta cada 30 minutos', + cronTooltipExampleHourly: '0 * * * *: se ejecuta al comienzo de cada hora', + cronTooltipExampleDaily: '0 9 * * *: se ejecuta todos los días a las 09:00', + cronTooltipExampleWeekly: '0 9 * * 1: se ejecuta todos los lunes a las 09:00', + cronTooltipRange: 'Rango recomendado: usa cron estándar de 5 campos. Para comprobaciones de salud, empieza con una frecuencia moderada como cada 30 minutos, cada hora o una vez al día en lugar de ejecutarlas demasiado a menudo.', + maxResultsTooltipTitle: 'Qué significa «Resultados máximos»:', + maxResultsTooltipMeaning: 'Define cuántos resultados históricos se conservan por plan para que la lista no crezca sin límite.', + maxResultsTooltipBody: 'Solo se conservan los resultados más recientes. Cuando el número de resultados guardados supera este valor, los más antiguos se eliminan automáticamente para mantener bajo control el historial y el almacenamiento.', + maxResultsTooltipExample: 'Por ejemplo, 100 significa conservar como mucho los últimos 100 resultados. Cuando se guarda el resultado 101, se elimina el más antiguo.', + maxResultsTooltipRange: 'Rango recomendado: normalmente entre 20 y 200. Usa 20-50 si solo te importa el estado reciente, o 100-200 si quieres un histórico de tendencia más largo.', + autoRecover: 'Recuperación automática', + autoRecoverHelp: 'Recupera automáticamente la cuenta del estado de error o límite alcanzado cuando una prueba sale bien' + }, + + // Proxies + proxies: { + title: 'Gestión de proxies', + description: 'Gestiona los servidores proxy de las cuentas', + createProxy: 'Crear un proxy', + editProxy: 'Editar el proxy', + deleteProxy: 'Eliminar el proxy', + ad: { + inline: '¿Necesitas una IP de proxy?' + }, + deleteConfirmMessage: '¿Seguro que quieres eliminar el proxy «{name}»?', + testProxy: 'Probar el proxy', + dataImport: 'Importar', + dataExportSelected: 'Exportar los seleccionados', + dataImportTitle: 'Importar proxies', + dataImportHint: 'Sube el archivo JSON de proxies exportado para importarlos en bloque.', + dataImportWarning: 'La importación creará o reutilizará proxies, conservará su estado y lanzará comprobaciones de latencia al terminar.', + dataImportFile: 'Archivo de datos', + dataImportButton: 'Comenzar la importación', + dataImporting: 'Importando...', + dataImportSelectFile: 'Selecciona un archivo de datos', + dataImportParseFailed: 'No se pudieron interpretar los datos', + dataImportFailed: 'No se pudieron importar los datos', + dataImportResult: 'Resultado de la importación', + dataImportResultSummary: 'Creados {proxy_created}, reutilizados {proxy_reused}, fallidos {proxy_failed}', + dataImportErrors: 'Detalle de los fallos', + dataImportSuccess: 'Importación completada: creados {proxy_created}, reutilizados {proxy_reused}', + dataImportCompletedWithErrors: 'La importación terminó con errores: fallidos {proxy_failed}', + dataExport: 'Exportar', + dataExportConfirmMessage: 'Los datos exportados contienen información sensible de los proxies. Guárdalos en un lugar seguro.', + dataExportConfirm: 'Confirmar la exportación', + dataExported: 'Datos exportados correctamente', + dataExportFailed: 'No se pudieron exportar los datos', + copyProxyUrl: 'Copiar la URL del proxy', + urlCopied: 'URL del proxy copiada', + searchProxies: 'Buscar proxies...', + allProtocols: 'Todos los protocolos', + allStatus: 'Todos los estados', + protocols: { + http: 'HTTP', + https: 'HTTPS', + socks5: 'SOCKS5', + socks5h: 'SOCKS5H (DNS remoto)' + }, + columns: { + name: 'Nombre', + protocol: 'Protocolo', + address: 'Dirección', + auth: 'Autenticación', + location: 'Ubicación', + status: 'Estado', + accounts: 'Cuentas', + latency: 'Latencia', + expiry: 'Validez', + createdAt: 'Creado', + actions: 'Acciones', + nameLabel: 'Nombre', + namePlaceholder: 'Introduce el nombre del proxy', + protocolLabel: 'Protocolo', + selectProtocol: 'Selecciona el protocolo', + hostLabel: 'Servidor', + hostPlaceholder: 'Introduce la dirección del servidor', + portLabel: 'Puerto', + portPlaceholder: 'Introduce el puerto', + usernameLabel: 'Usuario (opcional)', + usernamePlaceholder: 'Introduce el usuario', + passwordLabel: 'Contraseña (opcional)', + passwordPlaceholder: 'Introduce la contraseña', + priorityLabel: 'Prioridad', + statusLabel: 'Estado' + }, + filters: { + protocol: 'Protocolo', + allProtocols: 'Todos los protocolos', + status: 'Estado', + allStatuses: 'Todos los estados' + }, + testConnection: 'Probar la conexión', + qualityCheck: 'Comprobar la calidad', + batchQualityCheck: 'Comprobar la calidad en bloque', + batchTest: 'Probar todos los proxies', + testFailed: 'Fallida', + latencyFailed: 'Falló la conexión', + batchTestEmpty: 'No hay proxies disponibles para probar', + batchTestDone: 'Prueba en bloque completada para {count} proxies', + batchTestFailed: 'Falló la prueba en bloque', + batchDeleteAction: 'Eliminar', + batchDelete: 'Eliminar en bloque', + batchDeleteConfirm: '¿Eliminar los {count} proxies seleccionados? Los que estén en uso se omitirán.', + batchDeleteDone: 'Se eliminaron {deleted} proxies y se omitieron {skipped}', + batchDeleteSkipped: 'Se omitieron {skipped} proxies', + batchDeleteFailed: 'Falló la eliminación en bloque', + deleteBlockedInUse: 'Este proxy está en uso y no se puede eliminar', + accountsTitle: 'Cuentas que usan esta IP', + accountsEmpty: 'Ninguna cuenta está usando este proxy', + accountsFailed: 'No se pudo cargar la lista de cuentas', + accountName: 'Cuenta', + accountPlatform: 'Plataforma', + accountNotes: 'Notas', + name: 'Nombre', + protocol: 'Protocolo', + host: 'Servidor', + port: 'Puerto', + username: 'Usuario (opcional)', + password: 'Contraseña (opcional)', + status: 'Estado', + enterProxyName: 'Introduce el nombre del proxy', + leaveEmptyToKeep: 'Déjalo vacío para conservar el actual', + optionalAuth: 'Autenticación opcional', + form: { + hostPlaceholder: 'proxy.ejemplo.com', + portPlaceholder: '8080' + }, + noProxiesYet: 'Todavía no hay proxies', + createFirstProxy: 'Crea tu primer proxy para dirigir el tráfico a través de él.', + // Batch import + standardAdd: 'Alta estándar', + batchAdd: 'Alta rápida', + batchInput: 'Lista de proxies', + batchInputPlaceholder: + "Introduce un proxy por línea con alguno de estos formatos:\nsocks5://usuario:contrasena{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://usuario:contrasena{'@'}proxy.ejemplo.com:443", + batchInputHint: + "Admite los protocolos http, https y socks5. Formato: protocolo://[usuario:contrasena{'@'}]servidor:puerto", + parsedCount: '{count} válidos', + invalidCount: '{count} no válidos', + duplicateCount: '{count} duplicados', + importing: 'Importando...', + importProxies: 'Importar {count} proxies', + batchImportSuccess: 'Se importaron correctamente {created} proxies y se omitieron {skipped} duplicados', + batchImportAllSkipped: 'Los {skipped} proxies ya existen, no se importó ninguno', + failedToImport: 'Falló la importación en bloque', + // Other messages + saving: 'Guardando...', + testing: 'Probando...', + creating: 'Creando...', + updating: 'Actualizando...', + noProxies: 'Todavía no hay proxies', + noProxiesDescription: 'Añade un servidor proxy para mejorar la estabilidad del acceso a la API.', + proxyCreated: 'Proxy creado correctamente', + proxyUpdated: 'Proxy actualizado correctamente', + proxyDeleted: 'Proxy eliminado correctamente', + proxyWorking: '¡El proxy funciona!', + proxyWorkingWithLatency: '¡El proxy funciona! Latencia: {latency} ms', + proxyTestFailed: 'Falló la prueba del proxy', + qualityCheckDone: 'Comprobación de calidad completada: puntuación {score} ({grade})', + qualityCheckFailed: 'No se pudo comprobar la calidad del proxy', + batchQualityDone: + 'Comprobación de calidad en bloque completada para {count} proxies: correctos {healthy}, con avisos {warn}, con desafío {challenge}, anómalos {failed}', + batchQualityFailed: 'Falló la comprobación de calidad en bloque', + batchQualityEmpty: 'No hay proxies disponibles para comprobar la calidad', + qualityReportTitle: 'Informe de calidad del proxy', + qualityGrade: 'Nota {grade}', + qualityExitIP: 'IP de salida', + qualityCountry: 'Región de salida', + qualityBaseLatency: 'Latencia base', + qualityCheckedAt: 'Comprobado el', + qualityTableTarget: 'Destino', + qualityTableStatus: 'Estado', + qualityTableLatency: 'Latencia', + qualityTableMessage: 'Mensaje', + qualityInline: 'Calidad {grade}/{score}', + qualityStatusHealthy: 'Correcto', + qualityStatusPass: 'Aprobado', + qualityStatusWarn: 'Aviso', + qualityStatusFail: 'Fallo', + qualityStatusChallenge: 'Desafío', + qualityTargetBase: 'Conectividad básica', + proxyCreatedSuccess: 'Proxy creado correctamente', + proxyUpdatedSuccess: 'Proxy actualizado correctamente', + proxyDeletedSuccess: 'Proxy eliminado correctamente', + testSuccess: 'El proxy pasó la prueba', + failedToLoad: 'No se pudieron cargar los proxies', + failedToSave: 'No se pudo guardar el proxy', + failedToCreate: 'No se pudo crear el proxy', + failedToUpdate: 'No se pudo actualizar el proxy', + failedToDelete: 'No se pudo eliminar el proxy', + failedToTest: 'No se pudo probar el proxy', + nameRequired: 'Introduce el nombre del proxy', + hostRequired: 'Introduce la dirección del servidor', + portInvalid: 'El puerto debe estar entre 1 y 65535', + deleteConfirm: + '¿Seguro que quieres eliminar «{name}»? A las cuentas que usen este proxy se les quitará.', + neverExpires: 'Nunca', + expired: 'Caducado', + overdueDays: 'Vencido hace {days} d', + expiringInDays: 'Caduca en {days} d', + remainingDays: 'Quedan {days} d', + expiresAt: 'Validez', + nDays: '{days} d', + expiryDaysPlaceholder: 'Días personalizados, vacío = nunca', + expiryWarnDays: 'Aviso de caducidad (días)', + fallbackMode: 'Alternativa al caducar', + fallbackNone: 'Sin alternativa', + fallbackProxy: 'Proxy de respaldo', + fallbackDirect: 'Conexión directa', + backupProxy: 'Proxy de respaldo', + }, + + // Redeem Codes + redeem: { + title: 'Gestión de códigos de canje', + description: 'Genera y gestiona códigos de canje', + generateCodes: 'Generar códigos', + searchCodes: 'Buscar códigos o correo...', + allTypes: 'Todos los tipos', + allStatus: 'Todos los estados', + balance: 'Saldo', + concurrency: 'Concurrencia', + subscription: 'Suscripción', + invitation: 'Invitación', + invitationHint: 'Los códigos de invitación sirven para restringir el registro de usuarios. Se marcan como usados automáticamente tras utilizarlos.', + unused: 'Sin usar', + used: 'Usado', + columns: { + code: 'Código', + type: 'Tipo', + value: 'Valor', + status: 'Estado', + usedBy: 'Usado por', + usedAt: 'Usado el', + createdAt: 'Creado el', + expiresAt: 'Caduca el', + actions: 'Acciones' + }, + userPrefix: 'Usuario n.º {id}', + exportCsv: 'Exportar CSV', + batchUpdate: 'Actualizar en bloque', + batchUpdateTitle: 'Actualizar códigos de canje en bloque', + selectedCount: '{count} código(s) de canje seleccionado(s)', + clearSelection: 'Limpiar la selección', + selectCodesFirst: 'Selecciona primero los códigos de canje', + noBatchFieldsSelected: 'Selecciona al menos un campo para actualizar', + batchUpdateSuccess: 'Se actualizaron {count} código(s) de canje', + failedToBatchUpdate: 'No se pudieron actualizar los códigos de canje en bloque', + batchFields: { + status: 'Estado', + expiresAt: 'Caduca el', + notes: 'Notas', + group: 'Grupo' + }, + batchNotesPlaceholder: 'Introduce la nota nueva, o déjalo vacío para borrarla', + clearGroup: 'Quitar el grupo', + deleteAllUnused: 'Eliminar todos los códigos sin usar', + deleteCode: 'Eliminar el código de canje', + deleteCodeConfirm: + '¿Seguro que quieres eliminar este código de canje? Esta acción no se puede deshacer.', + deleteAllUnusedConfirm: + '¿Seguro que quieres eliminar todos los códigos de canje sin usar (activos)? Esta acción no se puede deshacer.', + deleteAll: 'Eliminar todos', + generateCodesTitle: 'Generar códigos de canje', + generatedSuccessfully: 'Generados correctamente', + codesCreated: 'Se crearon {count} código(s) de canje', + codeType: 'Tipo de código', + amount: 'Importe ($)', + value: 'Valor', + count: 'Cantidad', + generating: 'Generando...', + generate: 'Generar', + copyAll: 'Copiar todos', + copied: '¡Copiado!', + download: 'Descargar', + codesExported: 'Códigos exportados correctamente', + codeDeleted: 'Código de canje eliminado correctamente', + codesDeleted: 'Se eliminaron correctamente {count} código(s) sin usar', + noUnusedCodes: 'No hay códigos sin usar que eliminar', + failedToLoad: 'No se pudieron cargar los códigos de canje', + failedToGenerate: 'No se pudieron generar los códigos', + failedToExport: 'No se pudieron exportar los códigos', + failedToDelete: 'No se pudo eliminar el código', + failedToDeleteUnused: 'No se pudieron eliminar los códigos sin usar', + failedToCopy: 'No se pudieron copiar los códigos', + types: { + balance: 'Saldo', + concurrency: 'Concurrencia', + subscription: 'Suscripción', + invitation: 'Invitación', + // Admin adjustment types (created when admin modifies user balance/concurrency) + admin_balance: 'Saldo (administrador)', + admin_concurrency: 'Concurrencia (administrador)' + }, + selectGroup: 'Elegir el grupo', + selectGroupPlaceholder: 'Elige un grupo de suscripción', + validityDays: 'Días de validez', + codeExpiry: 'Caducidad del código', + neverExpires: 'No caduca nunca', + expiryPresetDays: '{days} días', + customExpiry: 'Personalizado', + customExpiryDays: 'Días personalizados', + expiryDaysRequired: 'Introduce un número de días de caducidad válido', + groupRequired: 'Selecciona un grupo de suscripción', + days: ' días', + status: { + unused: 'Sin usar', + used: 'Usado', + expired: 'Caducado', + disabled: 'Desactivado' + }, + form: { + typeLabel: 'Tipo', + selectType: 'Selecciona el tipo', + valueLabel: 'Valor', + valuePlaceholder: 'Introduce el valor', + balanceHint: 'Importe del saldo (USD)', + concurrencyHint: 'Incremento de concurrencia', + countLabel: 'Cantidad', + countPlaceholder: 'Introduce la cantidad', + countHint: 'Número de códigos de canje a generar', + prefixLabel: 'Prefijo (opcional)', + prefixPlaceholder: 'p. ej. REGALO', + expiresLabel: 'Caduca el (opcional)' + }, + filters: { + type: 'Tipo', + allTypes: 'Todos los tipos', + status: 'Estado', + allStatuses: 'Todos los estados', + search: 'Buscar códigos' + }, + copyCode: 'Copiar', + disableCode: 'Desactivar', + enableCode: 'Activar', + deleteConfirmMessage: '¿Seguro que quieres eliminar este código de canje?', + noCodes: 'Todavía no hay códigos de canje', + noCodesDescription: 'Genera códigos de canje para repartir saldo o concurrencia entre tus usuarios.', + codesGeneratedSuccess: 'Códigos de canje generados correctamente, {count} en total', + codeDisabledSuccess: 'Código de canje desactivado', + codeEnabledSuccess: 'Código de canje activado', + codeDeletedSuccess: 'Código de canje eliminado correctamente', + failedToUpdate: 'No se pudo actualizar el código de canje' + }, + + // Announcements + announcements: { + title: 'Anuncios', + description: 'Crea anuncios y dirígelos según condiciones', + createAnnouncement: 'Crear un anuncio', + editAnnouncement: 'Editar el anuncio', + deleteAnnouncement: 'Eliminar el anuncio', + searchAnnouncements: 'Buscar anuncios...', + status: 'Estado', + allStatus: 'Todos los estados', + columns: { + title: 'Título', + status: 'Estado', + notifyMode: 'Modo de aviso', + targeting: 'Destinatarios', + timeRange: 'Programación', + createdAt: 'Creado el', + actions: 'Acciones' + }, + statusLabels: { + draft: 'Borrador', + active: 'Activo', + archived: 'Archivado' + }, + notifyModeLabels: { + silent: 'Silencioso', + popup: 'Ventana emergente' + }, + form: { + title: 'Título', + content: 'Contenido (admite Markdown)', + status: 'Estado', + notifyMode: 'Modo de aviso', + notifyModeHint: 'El modo de ventana emergente mostrará un aviso emergente a los usuarios', + startsAt: 'Empieza el', + endsAt: 'Termina el', + startsAtHint: 'Déjalo vacío para empezar de inmediato', + endsAtHint: 'Déjalo vacío para que no caduque nunca', + targetingMode: 'Destinatarios', + targetingAll: 'Todos los usuarios', + targetingCustom: 'Reglas personalizadas', + addOrGroup: 'Añadir un grupo O', + addAndCondition: 'Añadir una condición Y', + conditionType: 'Tipo de condición', + conditionSubscription: 'Suscripción', + conditionBalance: 'Saldo', + operator: 'Operador', + balanceValue: 'Umbral de saldo', + selectPackages: 'Selecciona los paquetes' + }, + operators: { + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + eq: '=' + }, + targetingSummaryAll: 'Todos los usuarios', + targetingSummaryCustom: 'Personalizado ({groups} grupos)', + timeImmediate: 'Inmediato', + timeNever: 'Nunca', + readStatus: 'Estado de lectura', + preview: 'Vista previa', + eligible: 'Cumple los requisitos', + readAt: 'Leído el', + unread: 'Sin leer', + searchUsers: 'Buscar usuarios...', + failedToLoad: 'No se pudieron cargar los anuncios', + failedToCreate: 'No se pudo crear el anuncio', + failedToUpdate: 'No se pudo actualizar el anuncio', + failedToDelete: 'No se pudo eliminar el anuncio', + failedToLoadReadStatus: 'No se pudo cargar el estado de lectura', + deleteConfirm: '¿Seguro que quieres eliminar este anuncio? Esta acción no se puede deshacer.' + }, + + // Promo Codes + promo: { + title: 'Gestión de códigos promocionales', + description: 'Crea y gestiona códigos promocionales de registro', + createCode: 'Crear un código promocional', + editCode: 'Editar el código promocional', + deleteCode: 'Eliminar el código promocional', + searchCodes: 'Buscar códigos...', + allStatus: 'Todos los estados', + columns: { + code: 'Código', + bonusAmount: 'Importe de regalo', + maxUses: 'Usos máximos', + usedCount: 'Usados', + usage: 'Uso', + status: 'Estado', + expiresAt: 'Caduca el', + createdAt: 'Creado el', + actions: 'Acciones' + }, + // Form labels (flat structure for template usage) + code: 'Código promocional', + autoGenerate: 'se genera solo si lo dejas vacío', + codePlaceholder: 'Introduce el código promocional o déjalo vacío', + bonusAmount: 'Importe de regalo ($)', + maxUses: 'Usos máximos', + zeroUnlimited: '0 = ilimitado', + expiresAt: 'Caduca el', + notes: 'Notas', + notesPlaceholder: 'Notas opcionales para este código', + status: 'Estado', + neverExpires: 'No caduca nunca', + // Status labels + statusActive: 'Activo', + statusDisabled: 'Desactivado', + statusExpired: 'Caducado', + statusMaxUsed: 'Agotado', + // Usage records + usageRecords: 'Registros de uso', + viewUsages: 'Ver los usos', + noUsages: 'Todavía no hay registros de uso', + userPrefix: 'Usuario n.º {id}', + copied: '¡Copiado!', + // Messages + noCodesYet: 'Todavía no hay códigos promocionales', + createFirstCode: 'Crea tu primer código promocional para ofrecer regalos de bienvenida.', + codeCreated: 'Código promocional creado correctamente', + codeUpdated: 'Código promocional actualizado correctamente', + codeDeleted: 'Código promocional eliminado correctamente', + deleteCodeConfirm: '¿Seguro que quieres eliminar este código promocional? Esta acción no se puede deshacer.', + copyRegisterLink: 'Copiar el enlace de registro', + registerLinkCopied: 'Enlace de registro copiado al portapapeles', + failedToLoad: 'No se pudieron cargar los códigos promocionales', + failedToCreate: 'No se pudo crear el código promocional', + failedToUpdate: 'No se pudo actualizar el código promocional', + failedToDelete: 'No se pudo eliminar el código promocional', + failedToLoadUsages: 'No se pudieron cargar los registros de uso' + }, + + // Usage Records + usage: { + title: 'Registros de consumo', + description: 'Consulta y gestiona todos los registros de consumo de los usuarios', + userFilter: 'Usuario', + searchUserPlaceholder: 'Buscar usuario por correo...', + searchApiKeyPlaceholder: 'Buscar clave API por nombre...', + searchAccountPlaceholder: 'Buscar cuenta por nombre...', + selectedUser: 'Seleccionado', + user: 'Usuario', + account: 'Cuenta', + group: 'Grupo', + requestId: 'ID de la petición', + requestIdCopied: 'ID de la petición copiado', + allModels: 'Todos los modelos', + allAccounts: 'Todas las cuentas', + allGroups: 'Todos los grupos', + allTypes: 'Todos los tipos', + inputCost: 'Coste de entrada', + outputCost: 'Coste de salida', + cacheCreationCost: 'Coste de creación de caché', + cacheReadCost: 'Coste de lectura de caché', + inputTokens: 'Tokens de entrada', + outputTokens: 'Tokens de salida', + cacheCreationTokens: 'Tokens de creación de caché', + cacheCreation5mTokens: 'Escritura de caché', + cacheCreation1hTokens: 'Escritura de caché', + cacheReadTokens: 'Tokens de lectura de caché', + failedToLoad: 'No se pudieron cargar los registros de consumo', + billingType: 'Tipo de facturación', + allBillingTypes: 'Todos los tipos de facturación', + billingTypeBalance: 'Saldo', + billingTypeSubscription: 'Suscripción', + billingMode: 'Modo de facturación', + billingModeToken: 'Por token', + billingModePerRequest: 'Por petición', + billingModeImage: 'Por imagen', + billingModeVideo: 'Por vídeo', + allBillingModes: 'Todos los modos de facturación', + upstreamModelAudit: 'Auditoría del modelo del proveedor', + allUpstreamModelAudit: 'Todos los estados del modelo de respuesta', + upstreamModelMismatchOnly: 'Solo los que no coinciden', + upstreamModelMatchedOnly: 'Solo los que coinciden', + ipAddress: 'IP', + clickToViewBalance: 'Pulsa para ver el historial de saldo', + failedToLoadUser: 'No se pudo cargar la información del usuario', + userDeletedBadge: 'Eliminado', + tokenRanking: { + subtitle: 'Consumo de tokens por usuario con los filtros y el rango de fechas actuales', + rowHint: 'Pulsa para ver los detalles de consumo de este usuario', + userCount: '{count} usuarios', + columns: { + user: 'Usuario', + requests: 'Peticiones', + inputTokens: 'Tokens de entrada', + outputTokens: 'Tokens de salida', + cacheTokens: 'Tokens de caché', + totalTokens: 'Tokens totales', + cost: 'Coste' + } + }, + cleanup: { + button: 'Limpiar', + title: 'Limpiar los registros de consumo', + warning: 'La limpieza no se puede deshacer y afectará a las estadísticas históricas.', + submit: 'Enviar la limpieza', + submitting: 'Enviando...', + confirmTitle: 'Confirmar la limpieza', + confirmMessage: '¿Seguro que quieres enviar esta tarea de limpieza? Esta acción no se puede deshacer.', + confirmSubmit: 'Confirmar la limpieza', + cancel: 'Cancelar', + cancelConfirmTitle: 'Confirmar la cancelación', + cancelConfirmMessage: '¿Seguro que quieres cancelar esta tarea de limpieza?', + cancelConfirm: 'Confirmar la cancelación', + cancelSuccess: 'Tarea de limpieza cancelada', + cancelFailed: 'No se pudo cancelar la tarea de limpieza', + recentTasks: 'Tareas de limpieza recientes', + loadingTasks: 'Cargando las tareas...', + noTasks: 'Todavía no hay tareas de limpieza', + range: 'Rango', + deletedRows: 'Eliminados', + missingRange: 'Selecciona un rango de fechas', + submitSuccess: 'Tarea de limpieza creada', + submitFailed: 'No se pudo crear la tarea de limpieza', + loadFailed: 'No se pudieron cargar las tareas de limpieza', + status: { + pending: 'Pendiente', + running: 'En ejecución', + succeeded: 'Correcta', + failed: 'Fallida', + canceled: 'Cancelada' + } + } + }, + + // Ops Monitoring +} diff --git a/frontend/src/i18n/locales/es/admin/settings.ts b/frontend/src/i18n/locales/es/admin/settings.ts new file mode 100644 index 00000000000..02ce7ad5a01 --- /dev/null +++ b/frontend/src/i18n/locales/es/admin/settings.ts @@ -0,0 +1,1449 @@ +export default { + settings: { + title: 'Ajustes del sistema', + description: 'Gestiona el registro, la verificación por correo, los valores por defecto y los ajustes de SMTP', + tabs: { + general: 'General', + agreement: 'Acuerdo', + features: 'Interruptores de funciones', + security: 'Seguridad', + users: 'Usuarios', + gateway: 'Pasarela', + email: 'Correo', + backup: 'Copias de seguridad', + payment: 'Pagos', + }, + features: { + channelMonitor: { + title: 'Monitor de canales', + description: 'Elige entre las sondas activas V1 o la monitorización pasiva de consumo V2. Si se desactiva, se detienen ambas tareas en segundo plano y se oculta el acceso para los usuarios.', + configureLink: 'Configura los monitores en Gestión de canales > Monitor de canales', + enabled: 'Activar el monitor de canales', + enabledHint: 'Desactivarlo detiene tanto el planificador V1 como la agregación V2; la configuración y el histórico se conservan.', + mode: 'Modo del monitor', + modeHint: 'Por defecto es V1 (sondas activas). Cambia a V2 solo si quieres agregación pasiva; solo puede estar activa una implementación a la vez.', + modeV2: 'Monitorización pasiva V2', + modeV1: 'Sondas activas V1', + modeV2Hint: 'Opcional: agrega métricas de salud a partir del tráfico real de la pasarela, sin tráfico de sondeo al proveedor. Las sondas V1 se detienen mientras V2 está activo.', + modeV1Hint: 'Por defecto: lanza comprobaciones de salud programadas contra el proveedor para los monitores configurados (tráfico de sondeo).', + defaultInterval: 'Intervalo de comprobación por defecto (segundos)', + defaultIntervalHint: 'Solo para V1: intervalo por defecto de los monitores nuevos (se puede cambiar en cada uno). Rango de 15 a 3600 segundos.', + hideThroughput: 'Ocultar a los usuarios las tasas de rendimiento (RPM / TPM)', + hideThroughputHint: + 'Si se activa, la página de monitor de canales y las API de usuario omiten RPM y TPM para que no se pueda deducir el volumen de tu operación multiplicando tasa × ventana. Los administradores siguen viendo todas las métricas. Las tasas de error, la latencia y la caché siguen visibles.', + }, + availableChannels: { + title: 'Canales disponibles', + description: 'Muestra a los usuarios con sesión iniciada una vista agregada de los canales, modelos y precios a los que pueden acceder. Desactivado por defecto.', + configureLink: 'Configura los precios de los modelos en Gestión de canales > Precios por canal', + enabled: 'Activar los canales disponibles', + enabledHint: 'Si está desactivado, la entrada de la barra lateral se oculta y el endpoint devuelve una lista vacía.', + }, + modelPlaza: { + title: 'Catálogo de modelos', + description: 'Una página pública que muestra los modelos disponibles y sus precios por grupo. Desactivada por defecto.', + enabled: 'Activar el catálogo de modelos', + enabledHint: 'Si se activa, aparece una entrada en la cabecera y la página queda accesible en /model-plaza.', + requireAuth: 'Exigir iniciar sesión para acceder', + requireAuthHint: 'Si se activa, a las visitas anónimas se les redirige al inicio de sesión; si no, la página es pública y las visitas anónimas solo ven los grupos no exclusivos.', + priceDescription: 'Notas sobre los precios (Markdown)', + priceDescriptionHint: 'Se muestran en la parte superior de la página del catálogo. Úsalas para reglas de facturación, tipos de cambio, promociones, etc.', + }, + riskControl: { + title: 'Control de riesgos', + description: 'Activa el menú de moderación de contenido y el punto de auditoría en la pasarela. Desactivado por defecto.', + configureLink: 'Configura la moderación de contenido en Control de riesgos', + enabled: 'Activar el control de riesgos', + enabledHint: 'Si está desactivado, se oculta la entrada de la barra lateral de administración y la pasarela se salta la moderación.', + cyberSessionBlock: 'Bloqueo automático de sesiones cibernéticas', + cyberSessionBlockHint: 'Si se activa, las sesiones que el proveedor marque con cyber_policy se bloquean localmente durante el tiempo indicado y dejan de reenviarse. Solo se bloquea la sesión infractora; el resto de sesiones de la misma clave no se ven afectadas.', + cyberSessionBlockTTL: 'Duración del bloqueo (segundos)', + }, + affiliate: { + title: 'Afiliados (comisiones por invitación)', + description: 'Los usuarios existentes invitan a nuevos; quien invita gana un porcentaje de las recargas del invitado. Desactivado por defecto.', + enabled: 'Activar los afiliados', + enabledHint: 'Si está desactivado, se oculta el menú de afiliados, se ignora el parámetro aff en el registro y las recargas nuevas no generan comisión. El saldo de comisiones ya ganado se puede seguir transfiriendo.', + adminRechargeRebate: 'Generar comisión con los ingresos de administrador', + adminRechargeRebateHint: 'Si se activa, el saldo añadido desde Gestión de usuarios > Ingresar genera comisiones de afiliado. Definir un saldo o retirar fondos no las genera.', + rebateRate: 'Porcentaje de comisión global', + rebateRateHint: 'Porcentaje por defecto que se devuelve a quien invita sobre las recargas (0-100, p. ej. 10 = 10 %).', + freezeHours: 'Periodo de retención de la comisión (horas)', + freezeHoursDesc: 'Las comisiones nuevas quedan retenidas este tiempo antes de poder retirarse. 0 = sin retención.', + durationDays: 'Duración de la comisión (días)', + durationDaysDesc: 'La relación de afiliado caduca pasados estos días desde que se registró el invitado. 0 = permanente.', + perInviteeCap: 'Tope de comisión por invitado', + perInviteeCapDesc: 'Comisión total máxima que puede generar un mismo invitado. 0 = sin límite.', + customUsers: { + title: 'Excepciones por usuario', + description: 'Define un código de invitación propio o un porcentaje de comisión exclusivo para usuarios concretos. Solo se listan los usuarios que tienen alguna excepción.', + addButton: 'Añadir un usuario personalizado', + searchPlaceholder: 'Buscar por correo o nombre de usuario', + batchButton: 'Definir el porcentaje en bloque ({count} seleccionados)', + empty: 'Todavía no hay usuarios con ajustes de afiliado personalizados', + customBadge: 'personalizado', + useGlobal: 'usar el global', + resetTitle: 'Restablecer los ajustes personalizados', + resetMessage: '¿Restablecer todos los ajustes personalizados de {email}?\n• Se borrará el porcentaje de comisión exclusivo (volverá al global)\n• El código de invitación se regenerará como código del sistema (los enlaces que ya se hayan compartido dejarán de funcionar)', + totalLabel: '{total} en total', + col: { + email: 'Correo electrónico', + username: 'Nombre de usuario', + code: 'Código de invitación', + rate: 'Porcentaje personalizado', + actions: 'Acciones', + }, + }, + modal: { + addTitle: 'Añadir un usuario personalizado', + editTitle: 'Editar los ajustes personalizados', + userLabel: 'Usuario', + userPlaceholder: 'Buscar por correo o nombre de usuario', + changeUser: 'Cambiar de usuario', + codeLabel: 'Código de invitación personalizado (opcional)', + codePlaceholder: 'p. ej. VIP2026', + codeHint: 'De 4 a 32 caracteres; A-Z, 0-9, guion bajo y guion. Déjalo vacío para conservar el actual. Se convierte a mayúsculas.', + rateLabel: 'Porcentaje de comisión exclusivo (opcional)', + ratePlaceholder: 'p. ej. 30', + rateHint: 'De 0 a 100. Déjalo vacío (en modo edición) para borrarlo y volver al porcentaje global.', + errorBadRate: 'Introduce un número entre 0 y 100', + errorEmpty: 'Rellena al menos uno: el código de invitación personalizado o el porcentaje exclusivo', + }, + batchModal: { + title: 'Definir el porcentaje en bloque ({count} usuarios seleccionados)', + hint: 'Aplica el mismo porcentaje de comisión exclusivo a todos los usuarios seleccionados.', + placeholder: 'p. ej. 30', + clearHint: 'Si lo envías vacío, se borrará el porcentaje exclusivo de los usuarios seleccionados.', + }, + }, + }, + emailTabDisabledTitle: 'La verificación por correo no está activada', + emailTabDisabledHint: 'Activa la verificación por correo en la pestaña Seguridad para configurar los ajustes de SMTP.', + registration: { + title: 'Ajustes de registro', + description: 'Controla el registro de usuarios y la verificación', + enableRegistration: 'Activar el registro', + enableRegistrationHint: 'Permitir que se registren usuarios nuevos', + emailVerification: 'Verificación por correo', + emailVerificationHint: 'Exigir verificación por correo en los registros nuevos', + emailSuffixWhitelist: 'Lista de dominios de correo permitidos', + emailSuffixWhitelistHint: + "Solo podrán registrarse las direcciones de los dominios indicados; déjalo vacío para no restringir (por ejemplo, {'@'}qq.com, {'@'}gmail.com, *.edu.mx)", + emailSuffixWhitelistPlaceholder: "{'@'}ejemplo.com, *.edu.mx", + emailSuffixWhitelistInputHint: 'Déjalo vacío para no restringir. Usa *.edu.mx para incluir edu.mx y sus subdominios.', + emailDomainQuota: 'Cuota para dominios fuera de la lista', + emailDomainQuotaHint: + 'Si se activa y la lista no está vacía, cualquier otro dominio válido podrá registrar una cuenta. Si se desactiva, los dominios que no estén en la lista se rechazan. No hace nada mientras la lista esté vacía', + promoCode: 'Código promocional', + promoCodeHint: 'Permitir que los usuarios usen códigos promocionales al registrarse', + invitationCode: 'Registro con código de invitación', + invitationCodeHint: 'Si se activa, los usuarios tendrán que introducir un código de invitación válido para registrarse', + passwordReset: 'Restablecer contraseña', + passwordResetHint: 'Permitir que los usuarios restablezcan su contraseña por correo', + frontendUrl: 'URL del frontend', + frontendUrlPlaceholder: 'https://ejemplo.com', + frontendUrlHint: 'Se usa para generar los enlaces de restablecimiento de contraseña de los correos. Ejemplo: https://ejemplo.com', + totp: 'Verificación en dos pasos (2FA)', + totpHint: 'Permitir que los usuarios usen apps de autenticación como Google Authenticator', + totpKeyNotConfigured: + 'Configura primero TOTP_ENCRYPTION_KEY en las variables de entorno. Puedes generar una clave con: openssl rand -hex 32' + }, + security: { + passkey: 'Inicio de sesión con passkey', + passkeyHint: 'Permite el inicio de sesión sin contraseña y que los usuarios gestionen sus passkeys cuando la configuración de la parte confiante sea válida.', + passkeyConfigured: 'La configuración de la parte confiante de WebAuthn es válida.', + passkeyNotConfigured: 'Configura un RP ID válido y los orígenes HTTPS permitidos antes de activar el inicio de sesión con passkey.', + passkeyRPID: 'RP ID', + passkeyOrigins: 'Orígenes HTTPS permitidos', + passkeyValueNotConfigured: 'Sin configurar', + passkeyDeploymentHint: 'Pide a quien administra el servidor que ponga webauthn.enabled en true, configure webauthn.rp_id (solo el dominio) y webauthn.rp_origins (orígenes HTTPS completos), y reinicie el servicio.', + stepUp: 'Doble verificación reforzada en operaciones sensibles', + stepUpHint: 'Si se activa, las operaciones sensibles (exportar cuentas o proxies, crear y descargar copias de seguridad, cambiar la configuración de S3, ascender a administrador) requieren una verificación TOTP reciente (válida 15 minutos). Tu propia cuenta debe tener el 2FA activado antes de encenderlo; desactivarlo también requiere verificación reforzada.', + stepUpEnableRequiresTotp: 'Activa el 2FA (TOTP) en tu propia cuenta desde el perfil antes de encender la verificación reforzada.', + sessionBinding: 'Vinculación de la sesión a IP y navegador', + sessionBindingHint: 'Vincula las sesiones a la IP y al User-Agent del cliente. Cualquier cambio invalida la sesión de inmediato y obliga a iniciar sesión otra vez, lo que dificulta reutilizar credenciales robadas.', + auditRetention: 'Retención de los registros de auditoría (días)', + auditRetentionHint: 'Los registros de auditoría más antiguos se limpian automáticamente. Pon 0 para conservarlos siempre (solo borrado manual).' + }, + panelRateLimit: { + title: 'Límite de peticiones a la API del panel', + description: 'Limita las peticiones a la API del panel para que el sondeo frecuente (estadísticas de consumo, consultas del panel) no sature la base de datos', + proxySafeNote: 'Los endpoints autenticados se cuentan por cuenta de usuario, sin depender de la IP de origen, así que los proxys inversos y las salidas NAT compartidas nunca se bloquean por error. Los endpoints públicos se cuentan por IP real del cliente, y las direcciones locales o privadas (saltos internos del proxy) se saltan automáticamente.', + enabled: 'Activar el límite de peticiones del panel', + enabledHint: 'Limita los endpoints autenticados del panel por cuenta. Las peticiones que superen el umbral reciben un HTTP 429 y se recuperan solas cuando se reinicia la ventana.', + userRpm: 'Peticiones por cuenta', + userRpmHint: 'Total de peticiones a la API del panel permitidas por cuenta y minuto. El uso normal de la interfaz queda muy por debajo. 0 = ilimitado.', + heavyRpm: 'Consultas pesadas por cuenta', + heavyRpmHint: 'Consultas de agregación de consumo y panel permitidas por cuenta y minuto (son las más costosas para la base de datos). 0 = ilimitado.', + publicIpRpm: 'Endpoints públicos por IP', + publicIpRpmHint: 'Peticiones por minuto permitidas por IP real del cliente en los endpoints sin autenticar (p. ej. los ajustes públicos del sitio). 0 = ilimitado.', + perMinute: 'pet./min', + exemptAdmin: 'Eximir a los administradores', + exemptAdminHint: 'Si se activa, las cuentas de administrador se saltan los límites del panel para que las operaciones masivas nunca se frenen.', + saved: 'Ajustes del límite de peticiones del panel guardados', + saveFailed: 'No se pudieron guardar los ajustes del límite de peticiones del panel' + }, + turnstile: { + title: 'Cloudflare Turnstile', + description: 'Protección contra bots en el inicio de sesión y el registro', + enableTurnstile: 'Activar Turnstile', + enableTurnstileHint: 'Exigir la verificación de Cloudflare Turnstile', + siteKey: 'Site Key', + secretKey: 'Secret Key', + siteKeyHint: 'La consigues en tu panel de Cloudflare', + cloudflareDashboard: 'Panel de Cloudflare', + secretKeyHint: 'Clave de verificación del servidor (mantenla en secreto)', + secretKeyConfiguredHint: 'La clave secreta está configurada. Déjalo vacío para conservarla.' + }, + captcha: { + title: 'CAPTCHA', + description: 'Protección contra bots en el inicio de sesión y el registro', + enable: 'Activar el CAPTCHA', + enableHint: 'Exigir verificación humana al iniciar sesión, registrarse y en flujos relacionados', + provider: 'Proveedor', + providerTurnstile: 'Cloudflare Turnstile', + providerTencent: 'Captcha de Tencent', + providerAliyun: 'Captcha de Aliyun 2.0' + }, + tencentCaptcha: { + title: 'Captcha de Tencent', + description: 'Captcha deslizante para el inicio de sesión, el registro y la creación de cuentas de terceros', + enable: 'Activar el captcha de Tencent', + enableHint: 'Usar el captcha deslizante de Tencent en todos los flujos donde hoy se usa Turnstile', + keepExisting: 'Déjalo vacío para conservar el valor actual', + configured: 'Configurado. Déjalo vacío para conservarlo.', + required: 'Obligatorio antes de activarlo.', + mutualExclusion: 'El captcha de Tencent, Cloudflare Turnstile y el captcha de Aliyun son excluyentes entre sí. Activar uno desactiva los otros.', + region: 'Sitio del servicio', + regionCn: 'China continental', + regionIntl: 'Internacional', + regionHint: 'Determina el script del SDK y el endpoint de verificación del servidor. Debe coincidir con el sitio donde se creó tu CaptchaAppId; las aplicaciones internacionales se crean en la consola de tencentcloud.com.', + appCredentialsTitle: 'Credenciales de la aplicación de captcha', + appCredentialsHint: 'Consigue el CaptchaAppId y el AppSecretKey en la gestión de verificación de la consola de Captcha.', + cloudCredentialsTitle: 'Credenciales de la API de la nube', + cloudCredentialsHint: 'El SecretId y el SecretKey autorizan las peticiones DescribeCaptchaResult desde el servidor.', + appId: 'CaptchaAppId', + appSecretKey: 'AppSecretKey', + cloudSecretId: 'SecretId de Tencent Cloud', + cloudSecretKey: 'SecretKey de Tencent Cloud', + camPermissionHint: 'Crea un subusuario CAM con QcloudCaptchaFullAccess en lugar de usar las credenciales permanentes de la cuenta raíz.', + aidEncryptedHint: 'Todavía no se admite aidEncrypted. Deja desactivada la verificación obligatoria de CaptchaAppId en la consola de Captcha.', + openCaptchaConsole: 'Abrir la consola de Captcha', + createCloudKeys: 'Crear SecretId / SecretKey', + openWebDocs: 'Ver la guía de integración web' + }, + aliyunCaptcha: { + accessKeyId: 'AccessKey ID', + accessKeyIdHint: 'AccessKey ID de Alibaba Cloud para la verificación desde el servidor; se recomienda un usuario RAM dedicado solo al captcha', + accessKeySecret: 'AccessKey Secret', + accessKeySecretHint: 'Secreto de verificación del servidor (mantenlo en secreto)', + accessKeySecretConfiguredHint: 'El secreto está configurado. Déjalo vacío para conservarlo.', + sceneId: 'ID de escena', + sceneIdHint: 'Crea una escena de verificación en la consola de Captcha de Alibaba Cloud; el tipo de captcha (invisible, deslizante o puzle) se configura ahí por escena', + prefix: 'Prefijo', + prefixHint: 'Está en la información de la instancia, en la página de resumen de la consola', + region: 'Región', + regionCn: 'China continental', + regionSgp: 'Singapur', + regionHint: 'Determina la región del script del frontend y el endpoint del servidor; debe coincidir con la región de tu instancia de captcha' + }, + apiKeyAcl: { + title: 'Control de acceso por IP de las claves API', + description: + 'Elige qué IP de cliente usan las listas de permitidos y bloqueados de las claves API, los registros de auditoría y la vinculación de sesión por IP y navegador', + trustForwardedIp: 'Confiar en la IP de cliente reenviada', + trustForwardedIpHint: + 'Está activado por defecto por compatibilidad. Si se activa, los valores en bruto de CF-Connecting-IP, X-Real-IP o X-Forwarded-For mandan sobre server.trusted_proxies para determinar la IP del cliente. Desactívalo para aplicar la cadena de proxys de confianza configurada en server.trusted_proxies. Activa este modo solo cuando no se pueda llegar directamente al servidor de origen. Cambiar este interruptor cambia las huellas de IP de las sesiones existentes.', + forwardedClientIpHeaders: 'Cabeceras personalizadas de IP de cliente', + forwardedClientIpHeadersHint: 'Añade nombres de cabecera de tu CDN o proxy que se comprobarán antes que las integradas.', + forwardedClientIpHeadersPlaceholder: 'X-Client-IP', + forwardedClientIpHeadersRiskHint: 'Estas cabeceras en bruto se pueden falsificar si se puede llegar directamente al servidor de origen. Restringe el acceso al origen antes de confiar en ellas.', + forwardedClientIpHeaderInvalid: 'Introduce un nombre de cabecera HTTP válido.', + forwardedClientIpHeadersLimit: 'Se permiten como máximo {max} cabeceras personalizadas de IP de cliente.', + removeForwardedClientIpHeader: 'Quitar {header}' + }, + linuxdo: { + title: 'Inicio de sesión con LinuxDo Connect', + description: 'Configura el OAuth de LinuxDo Connect para el inicio de sesión de los usuarios de Sub2API', + enable: 'Activar el inicio de sesión con LinuxDo', + enableHint: 'Mostrar el inicio de sesión con LinuxDo en las páginas de acceso y registro', + clientId: 'Client ID', + clientIdPlaceholder: 'p. ej. hprJ5pC3...', + clientIdHint: 'Lo consigues en Connect.Linux.Do', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: 'Lo usa el servidor para intercambiar tokens (mantenlo en secreto)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'El secreto está configurado. Déjalo vacío para conservarlo.', + redirectUrl: 'URL de redirección', + redirectUrlPlaceholder: 'https://tu-dominio.com/api/v1/auth/oauth/linuxdo/callback', + redirectUrlHint: + 'Debe coincidir con la URL de redirección configurada en Connect.Linux.Do (tiene que ser una URL http(s) absoluta)', + quickSetCopy: 'Generar y copiar (sitio actual)', + redirectUrlSetAndCopied: 'URL de redirección generada y copiada al portapapeles' + }, + dingtalk: { + title: 'Inicio de sesión con DingTalk', + description: 'Configura el OAuth de DingTalk para el inicio de sesión de los usuarios de Sub2API', + enable: 'Activar el inicio de sesión con DingTalk (app corporativa interna)', + enableHint: 'Mostrar el inicio de sesión con DingTalk en las páginas de acceso y registro', + clientId: 'Client ID (AppKey)', + clientIdPlaceholder: 'p. ej. dingxxxxxxxxxxxxxxxx', + clientIdHint: 'Lo consigues en los detalles de la app en la plataforma abierta de DingTalk', + clientSecret: 'Client Secret (AppSecret)', + clientSecretPlaceholder: '********', + clientSecretHint: 'Lo usa el servidor para intercambiar tokens (mantenlo en secreto)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'El secreto está configurado. Déjalo vacío para conservarlo.', + redirectUrl: 'URL de redirección', + redirectUrlPlaceholder: 'https://tu-dominio.com/api/v1/auth/oauth/dingtalk/callback', + redirectUrlHint: + 'Debe coincidir con la URL de redirección configurada en la plataforma abierta de DingTalk (tiene que ser una URL http(s) absoluta)', + corpPolicy: { + label: 'Política de restricción por organización', + hint: 'Controla qué cuentas de DingTalk (organizaciones) pueden iniciar sesión', + none: 'Sin restricción (se permiten todas las cuentas de DingTalk)', + internalOnly: 'Solo internas (una sola organización)' + }, + bypassRegistration: 'Permitir el registro con DingTalk', + bypassRegistrationHint: 'Permite que se registren usuarios nuevos con DingTalk aunque el registro público esté desactivado.', + syncDisplayName: 'Sincronizar el nombre de DingTalk', + syncDisplayNameHint: 'Sobrescribe el nombre de usuario con el nombre del empleado en DingTalk en cada inicio de sesión (también se guarda en el atributo dingtalk_name).', + syncCorpEmail: 'Sincronizar el correo corporativo', + syncCorpEmailHint: 'Escribe el correo corporativo de DingTalk en el atributo dingtalk_email en cada inicio de sesión (no cambia el correo de acceso).', + syncCorpEmailPermissionHint: 'Requiere que la app tenga concedido en la plataforma abierta de DingTalk el permiso OAPI «Información personal incluido el correo (fieldEmail)»; si no, OAPI no devolverá el campo del correo.', + syncDept: 'Sincronizar el departamento', + syncDeptHint: 'Escribe la ruta completa del departamento de DingTalk en el atributo dingtalk_department en cada inicio de sesión (se consulta cada vez).', + syncDeptPermissionHint: 'Requiere que la app tenga concedido en la plataforma abierta de DingTalk el permiso OAPI «Lectura de información de departamentos (qyapi_get_department_list)»; si no, no se puede resolver la ruta del departamento.', + syncDisplayNameTarget: 'Clave del atributo', + syncDisplayNameTargetHint: 'Por defecto dingtalk_name / Nombre de DingTalk. Al guardar los ajustes se crea automáticamente el atributo de usuario con la clave y el nombre de arriba (si ya existe, solo se sincroniza su nombre visible).', + syncCorpEmailTarget: 'Clave del atributo', + syncCorpEmailTargetHint: 'Por defecto dingtalk_email / Correo corporativo de DingTalk. Al guardar los ajustes se crea automáticamente el atributo de usuario con la clave y el nombre de arriba (si ya existe, solo se sincroniza su nombre visible).', + syncDeptTarget: 'Clave del atributo', + syncDeptTargetHint: 'Por defecto dingtalk_department / Departamento de DingTalk. Al guardar los ajustes se crea automáticamente el atributo de usuario con la clave y el nombre de arriba (si ya existe, solo se sincroniza su nombre visible).', + syncAttrDisplayName: 'Nombre visible' + }, + oidc: { + title: 'Inicio de sesión con OIDC', + description: 'Configura un proveedor OIDC estándar (por ejemplo Keycloak)', + enable: 'Activar el inicio de sesión con OIDC', + enableHint: 'Mostrar el inicio de sesión con OIDC en las páginas de acceso y registro', + providerName: 'Nombre del proveedor', + providerNamePlaceholder: 'por ejemplo Keycloak', + clientId: 'Client ID', + clientIdPlaceholder: 'client id de OIDC', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: 'Lo usa el servidor para intercambiar tokens (mantenlo en secreto)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'El secreto está configurado. Déjalo vacío para conservarlo.', + issuerUrl: 'URL del emisor', + issuerUrlPlaceholder: 'https://id.ejemplo.com/realms/main', + discoveryUrl: 'URL de descubrimiento', + discoveryUrlPlaceholder: 'Opcional, déjalo vacío para deducirla del emisor', + authorizeUrl: 'URL de autorización', + authorizeUrlPlaceholder: 'Opcional, se puede descubrir automáticamente', + tokenUrl: 'URL de token', + tokenUrlPlaceholder: 'Opcional, se puede descubrir automáticamente', + userinfoUrl: 'URL de UserInfo', + userinfoUrlPlaceholder: 'Opcional, se puede descubrir automáticamente', + jwksUrl: 'URL de JWKS', + jwksUrlPlaceholder: 'Opcional, obligatoria si se activa la validación estricta del ID token', + scopes: 'Ámbitos', + scopesPlaceholder: 'openid email profile', + scopesHint: 'Debe incluir openid', + redirectUrl: 'URL de redirección del servidor', + redirectUrlPlaceholder: 'https://tu-dominio.com/api/v1/auth/oauth/oidc/callback', + redirectUrlHint: 'Debe coincidir con la URL de retorno configurada en el proveedor OIDC', + quickSetCopy: 'Generar y copiar (sitio actual)', + redirectUrlSetAndCopied: 'URL de redirección generada y copiada al portapapeles', + frontendRedirectUrl: 'Ruta de retorno del frontend', + frontendRedirectUrlPlaceholder: '/auth/oidc/callback', + frontendRedirectUrlHint: 'Ruta del frontend que se usa después del retorno del servidor', + tokenAuthMethod: 'Método de autenticación del token', + clockSkewSeconds: 'Desfase de reloj (segundos)', + allowedSigningAlgs: 'Algoritmos de firma permitidos', + allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', + usePkce: 'Usar PKCE', + validateIdToken: 'Validar el ID token', + requireEmailVerified: 'Exigir el correo verificado', + userinfoEmailPath: 'Ruta del correo en UserInfo', + userinfoEmailPathPlaceholder: 'por ejemplo data.email', + userinfoIdPath: 'Ruta del ID en UserInfo', + userinfoIdPathPlaceholder: 'por ejemplo data.id', + userinfoUsernamePath: 'Ruta del nombre de usuario en UserInfo', + userinfoUsernamePathPlaceholder: 'por ejemplo data.username' + }, + defaults: { + title: 'Valores por defecto de los usuarios', + description: 'Valores por defecto para los usuarios nuevos', + defaultBalance: 'Saldo por defecto', + defaultBalanceHint: 'Saldo inicial de los usuarios nuevos', + affiliateRebateRate: 'Porcentaje de comisión de afiliado', + affiliateRebateRateHint: + 'Porcentaje de comisión que se abona a quien invita tras una recarga (0-100 %, p. ej. 10 significa 10 %)', + defaultConcurrency: 'Concurrencia por defecto', + defaultConcurrencyHint: 'Máximo de peticiones simultáneas para los usuarios nuevos', + defaultUserRpmLimit: 'Límite de RPM por defecto', + defaultUserRpmLimitHint: 'Máximo de peticiones por minuto por defecto para los usuarios nuevos; 0 = ilimitado. Solo se aplica al crear el usuario.', + defaultSubscriptions: 'Suscripciones por defecto', + defaultSubscriptionsHint: 'Se asignan automáticamente cuando se crea o se registra un usuario nuevo', + addDefaultSubscription: 'Añadir una suscripción por defecto', + defaultSubscriptionsEmpty: 'No hay suscripciones por defecto configuradas.', + defaultSubscriptionsDuplicate: + 'Grupo de suscripción duplicado: {groupId}. Cada grupo solo puede aparecer una vez.', + subscriptionGroup: 'Grupo de suscripción', + subscriptionValidityDays: 'Validez (días)', + defaultPlatformQuotas: 'Cuotas por plataforma por defecto (al registrarse)', + defaultPlatformQuotasHint: 'Se asignan automáticamente a los usuarios nuevos al registrarse; los usuarios existentes no se ven afectados. Vacío = ilimitado.', + platformQuotaNotice: 'La cuota mensual usa una ventana móvil de 30 días, no el mes natural.', + }, + platformQuota: { + platform: 'Plataforma', + daily: 'Diaria (USD)', + weekly: 'Semanal (USD)', + monthly: 'Mensual (USD, 30 días móviles)', + placeholder: 'Ilimitada', + }, + claudeCode: { + title: 'Ajustes de Claude Code', + description: 'Controla los requisitos de acceso del cliente Claude Code', + minVersion: 'Versión mínima', + minVersionPlaceholder: 'p. ej. 2.1.63', + minVersionHint: + 'Rechaza los clientes de Claude Code por debajo de esta versión (formato semver). Déjalo vacío para no comprobar la versión.', + maxVersion: 'Versión máxima', + maxVersionPlaceholder: 'p. ej. 2.5.0', + maxVersionHint: + 'Rechaza los clientes de Claude Code por encima de esta versión (formato semver). Déjalo vacío para permitir cualquier versión.' + }, + scheduling: { + title: 'Ajustes de planificación de la pasarela', + description: 'Controla el comportamiento de planificación de las claves API', + allowUngroupedKey: 'Permitir planificar claves sin grupo', + allowUngroupedKeyHint: 'Si se desactiva, las claves API que no estén asignadas a ningún grupo no podrán hacer peticiones (403 Prohibido). Déjalo desactivado para asegurar que todas las claves pertenecen a un grupo concreto.', + accountSchedulingThresholdsTitle: 'Umbrales de pausa automática de cuentas por plataforma', + accountSchedulingThresholdsDescription: 'Cuando la ventana de consumo nativa de una cuenta (sesión de OpenAI Codex o Anthropic, o utilización de peticiones/tokens de Grok) alcanza este porcentaje, Sub2API la retira temporalmente de la planificación hasta que la ventana se reinicia. Usa 100 para desactivarlo.', + accountSchedulingThresholdsGlobalHint: 'Valor por defecto para todas las cuentas de esa plataforma. Cada cuenta puede tener su propio valor desde su editor.', + accountSchedulingThresholdsDisabledHint: '100 desactiva la pausa automática de la plataforma. Los valores de 1 a 99 pausan la planificación cuando la utilización alcanza ese porcentaje.', + accountSchedulingThresholdsRangeHint: 'Número entero de 1 a 100 (porcentaje). Solo OpenAI, Anthropic y Grok.' + }, + upstreamBillingProbe: { + title: 'Detección automática de tarifas del proveedor', + description: 'Consulta periódicamente las tarifas que declaran otros sitios Sub2API. Las tarifas de las cuentas solo cambian si activas el interruptor de sincronización correspondiente.', + enabled: 'Activar la detección automática global', + enabledHint: 'Si se activa, la detección programada solo se ejecuta en las cuentas que también la tengan activada. Desactivarlo detiene toda la detección programada; la detección manual sigue disponible.', + intervalMinutes: 'Intervalo de detección (minutos)', + intervalHint: 'Rango: de 5 a 1440 minutos. Un resultado correcto sigue siendo válido durante dos intervalos de detección.', + saved: 'Ajustes de detección automática de tarifas guardados', + saveFailed: 'No se pudieron guardar los ajustes de detección automática de tarifas' + }, + ollamaCloudUsage: { + title: 'Actualización del consumo de Ollama Cloud', + description: 'Actualiza el consumo que muestra la página oficial de ajustes de Ollama, a partir de las peticiones a modelos, en las cuentas que lo activen individualmente. Desactivado por defecto. Las cuentas inactivas no se consultan.', + enabled: 'Activar la actualización automática global', + enabledHint: 'Solo se actualizan las cuentas con una sesión de navegador guardada y su propio interruptor de actualización automática activado, y solo tras nuevas peticiones a modelos. La actualización manual sigue disponible.', + intervalMinutes: 'Espera máxima mientras siguen llegando peticiones (minutos)', + intervalHint: 'Rango: de 15 a 1440 minutos. Si las peticiones continuas van retrasando la espera, se fuerza una actualización pasado este tiempo.', + debounceMinutes: 'Periodo de calma tras la última petición (minutos)', + debounceHint: 'Rango: de 1 a 60 minutos. Se actualiza cuando ha pasado este tiempo desde la última petición a un modelo.', + saved: 'Ajustes de actualización del consumo de Ollama Cloud guardados', + saveFailed: 'No se pudieron guardar los ajustes de actualización del consumo de Ollama Cloud' + }, + gatewayForwarding: { + title: 'Reenvío de peticiones', + description: 'Controla cómo se reenvían las peticiones a las cuentas OAuth del proveedor', + grokDefaultTextModel: 'Modelo de texto de Grok por defecto', + grokDefaultTextModelHint: 'Se usa cuando el modelo llega vacío y, solo si el interruptor está activado, para peticiones desde otros espacios de nombres de modelo. Se admiten IDs de modelo de Grok personalizados.', + grokCrossClientMap: 'Redirigir otros clientes a Grok', + grokCrossClientMapHint: 'Desactivado por defecto. Si se activa, los IDs de modelo de GPT, Codex, la serie o y Claude se enrutan al modelo de texto de Grok de arriba.', + grokDefaultBaseURLMode: 'Proveedor de Grok por defecto', + grokDefaultBaseURLModeHint: 'Solo se usa cuando una cuenta de Grok no tiene una URL base explícita. Los endpoints multimedia y de voz siguen usando sus hosts oficiales.', + grokBaseURLModeCLI: 'Proxy de chat del CLI', + grokBaseURLModeAPI: 'API pública', + grokBaseURLModeUSEast1: 'API regional (us-east-1)', + grokBaseURLModeUSWest2: 'API regional (us-west-2)', + grokBaseURLModeEUWest1: 'API regional (eu-west-1)', + fingerprintUnification: 'Unificación de huellas', + fingerprintUnificationHint: 'Unifica las cabeceras X-Stainless-* entre los usuarios que comparten la misma cuenta OAuth. Si se desactiva, se pasan las cabeceras originales de cada cliente.', + metadataPassthrough: 'Paso directo de metadatos', + metadataPassthroughHint: 'Pasa el metadata.user_id original del cliente sin reescribirlo. Puede mejorar los aciertos de caché del proveedor.', + cchSigning: 'Firma CCH', + cchSigningHint: 'Firma la cabecera de facturación de las peticiones reenviadas con un hash CCH. Si se desactiva, se conserva el marcador de posición.', + claudeOAuthSystemPromptInjection: 'Bloques de sistema de Claude OAuth', + claudeOAuthSystemPromptInjectionHint: 'Inyecta bloques de sistema similares a los de Claude Code en las peticiones OAuth de Claude que vengan de clientes que no sean Claude Code. Activado por defecto.', + claudeOAuthSystemPrompt: 'Prompt de expansión de Claude OAuth', + claudeOAuthSystemPromptPlaceholder: 'Déjalo vacío para usar el prompt de expansión de Claude Code integrado.', + claudeOAuthSystemPromptHint: 'Compatibilidad heredada: solo controla el tercer bloque de sistema inyectado.', + claudeOAuthSystemPromptBlocks: 'Bloques de sistema de Claude OAuth', + claudeOAuthSystemPromptBlocksHint: "Cada bloque se guarda como JSON con enabled, type, text y opcionalmente cache_control. {'{'}billing_header{'}'} sigue siendo dinámico en cada petición; los prompts de identidad y expansión de Claude Code se pueden editar directamente o restaurar desde los valores predefinidos.", + systemBlockTitle: 'Bloque de sistema {index}', + systemBlockPreset: 'Valor predefinido', + systemBlockPresetBilling: 'Cabecera de facturación', + systemBlockPresetIdentity: 'Identidad de Claude Code', + systemBlockPresetExpansion: 'Expansión de Claude Code', + systemBlockPresetCustom: 'Personalizado', + systemBlockType: 'Tipo', + systemBlockTypeText: 'Texto', + systemBlockText: 'Contenido', + systemBlockCacheControl: 'Control de caché', + systemBlockHide: 'Ocultar los detalles del bloque', + systemBlockShow: 'Mostrar los detalles del bloque', + addSystemBlock: 'Añadir un bloque', + resetSystemBlocks: 'Restaurar los valores por defecto', + cacheTTL5m: '5 minutos', + cacheTTL1h: '1 hora', + anthropicCacheTTL1hInjection: 'Inyección de TTL de caché de Anthropic', + anthropicCacheTTL1hInjectionHint: 'Si se activa, los bloques cache_control efímeros que ya existan en las peticiones OAuth o con Setup Token de Anthropic se fuerzan a 1 h; el consumo de la respuesta se factura como 5 min por defecto, y la sustitución de TTL por cuenta tiene prioridad.', + rewriteMessageCacheControl: 'Reescribir los puntos de caché de los mensajes', + rewriteMessageCacheControlHint: 'Desactivado por defecto: se conserva el cache_control del cliente en los bloques de contenido de los mensajes. Si se activa, se quitan los puntos del cliente y se inyectan los del proxy, para clientes que no gestionan la caché por su cuenta.', + clientDatelineNormalization: 'Normalización de la línea de fecha del cliente', + clientDatelineNormalizationHint: 'Activado por defecto. Reescribe la frase «Today\'s date is …» de las peticiones OAuth o con Setup Token de Anthropic a un formato canónico con apóstrofo y guion ASCII, borrando los bits de huella esteganográfica que algunos clientes inyectan cuando detectan una URL base no oficial. Solo afecta a los prompts de sistema y a los bloques ; las cuentas con clave API no se ven afectadas.', + antigravityUserAgentVersion: 'Versión del UA de Antigravity', + antigravityUserAgentVersionPlaceholder: '1.23.2', + antigravityUserAgentVersionHint: 'Déjalo vacío para usar ANTIGRAVITY_USER_AGENT_VERSION o el valor por defecto 1.23.2; si lo rellenas, manda el ajuste del panel.', + openaiCodexUserAgent: 'UA de OpenAI Codex', + openaiCodexUserAgentPlaceholder: 'codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)', + openaiCodexUserAgentHint: 'El User-Agent completo de Codex que se usa en todas las peticiones salientes, para personalizar la huella de sistema operativo, arquitectura y terminal. Déjalo vacío para construir la identidad estándar codex-tui a partir de la versión de abajo (recomendado). Si lo rellenas, las declaraciones de versión del principio y del final se sincronizan con la versión de abajo, así que el UA nunca se queda anclado a la versión que escribas aquí: cuando hay presión de capacidad, el proveedor descarga tráfico por identidad de cliente y tira primero de las identidades obsoletas o no oficiales con server_is_overloaded.', + openaiCodexClientVersion: 'Versión del cliente Codex', + openaiCodexClientVersionPlaceholder: 'Déjalo vacío para seguir la sincronización automática', + openaiCodexClientVersionHint: 'La versión del cliente Codex que declara esta pasarela al proveedor, compartida por el User-Agent y la cabecera de versión. Déjalo vacío para usar la última versión estable sincronizada automáticamente; si pones un valor, se fija y deja de seguir la sincronización.', + openaiCodexVersionAutoSync: 'Sincronizar automáticamente la versión de Codex', + openaiCodexVersionAutoSyncHint: 'Consulta la última versión estable del cliente en el repositorio oficial cada 6 horas, para que no tengas que actualizar este servicio solo por mantener la versión al día. Si se desactiva, solo se usa la versión de arriba o el valor por defecto.', + openaiCodexVersionSyncedValue: 'Sincronizada ahora mismo: {version}', + codexHardeningTitle: 'Ajustes de Codex', + codexClientRestrictionTitle: 'Restricción de clientes de Codex', + codexHardeningDesc: + 'Solo afecta a las cuentas OAuth de OpenAI que tengan activado «solo clientes oficiales de Codex» (global). Además del User-Agent y el Originator, refuerza la decisión con un rango de versiones, una comprobación de huella del motor y listas de permitidos y bloqueados.', + minCodexVersion: 'Versión mínima de Codex', + minCodexVersionPlaceholder: 'p. ej. 0.142.0', + maxCodexVersion: 'Versión máxima de Codex', + maxCodexVersionPlaceholder: 'p. ej. 0.200.0', + codexVersionHint: + 'Solo clientes oficiales: comprueba su versión contra el rango [mín., máx.]. Deja un lado vacío para no limitarlo.', + codexFingerprintSignals: 'Señales de huella del motor de Codex', + codexFingerprintSignalsDesc: + 'Define señales de huella del motor: todas las señales marcadas como obligatorias deben coincidir (Y); dentro de una fila, las variantes separadas por «/» se combinan con O. Si no marcas ninguna, no se aplica. Por defecto solo comprueba el prefijo x-codex-. Tipos: cabecera exacta / prefijo de cabecera / ruta en el cuerpo.', + codexFpTypeHeaderExact: 'Cabecera exacta', + codexFpTypeHeaderPrefix: 'Prefijo de cabecera', + codexFpTypeBodyPath: 'Ruta en el cuerpo', + codexFpMatchPlaceholder: 'coincidencia; separa variantes con «/» (p. ej. session-id / session_id o x-codex-)', + codexFpRequired: 'Obligatoria', + codexFingerprintNoRequiredWarn: 'No hay ninguna señal marcada como obligatoria: la comprobación de huella del motor está inactiva y se permite a todo candidato que pase la identidad y la versión. Marca al menos una señal para activarla.', + codexAllowAppServer: 'App-server de Codex', + codexAllowAppServerDesc: + 'Permite clientes de terceros que integran el motor de Codex y se conectan por el protocolo app-server (p. ej. el plugin de codex de Claude Code). Desactivado por defecto; si se activa, esos clientes se permiten cuando pasan la comprobación de huella del motor (la lista de señales de abajo); desactivado = solo se permiten los clientes oficiales y la lista de permitidos.', + codexBlacklist: 'Lista de bloqueo de User-Agent/Originator', + codexBlacklistDesc: + 'Deniega si coincide cualquiera de los campos; tiene prioridad sobre cualquier permiso. El originator es exacto; el User-Agent se comprueba por «contiene» (separado por comas).', + codexWhitelist: 'Lista de permitidos de User-Agent/Originator', + codexWhitelistDesc: + 'Permite clientes fuera del conjunto oficial: requiere el originator exacto y que estén presentes todas las marcas del User-Agent. Sigue sujeto a la comprobación de huella salvo que marques «Saltarse la huella del motor».', + codexWhitelistSkipFingerprint: 'Saltarse la huella del motor', + codexWhitelistSkipFingerprintTooltip: + 'Riesgo: si lo marcas, esta entrada se permite solo con el originator y el User-Agent (ambos falsificables), sin la red de seguridad de la huella del motor. Úsalo solo con clientes de terceros de confianza que realmente no envíen huella del motor de codex.', + codexOriginatorPlaceholder: 'originator (exacto, p. ej. opencode)', + codexUaContainsPlaceholder: 'marcas que contiene el User-Agent, separadas por comas (p. ej. opencode/)', + codexAddRow: 'Añadir una entrada', + codexRemoveRow: 'Quitar', + }, + webSearchEmulation: { + title: 'Emulación de búsqueda web', + description: 'Añade capacidad de búsqueda web a las cuentas de Anthropic con clave API que no la admiten de forma nativa', + enabled: 'Activar la emulación de búsqueda web', + enabledHint: 'Interruptor global. Si se desactiva, la emulación de búsqueda web queda inactiva en todos los canales y cuentas.', + providers: 'Proveedores de búsqueda', + addProvider: 'Añadir un proveedor', + providerType: 'Tipo de proveedor', + apiKey: 'Clave API', + apiKeyPlaceholder: 'Introduce la clave API', + apiKeyConfigured: 'Configurada', + showApiKey: 'Mostrar', + hideApiKey: 'Ocultar', + copyApiKey: 'Copiar', + copied: 'Copiada', + quotaLimit: 'Límite de cuota', + quotaLimitHint: 'Déjalo vacío para ilimitado; si lo rellenas debe ser > 0', + quotaLimitMustBePositive: 'El límite de cuota debe ser mayor que 0', + subscribedAt: 'Suscrito el', + subscribedAtHint: 'La cuota se reinicia cada mes desde esta fecha; déjalo vacío para desactivar el reinicio automático', + quotaUsage: 'Consumo', + resetUsage: 'Restablecer', + resetUsageConfirm: '¿Poner a cero el contador de consumo de este proveedor?', + resetUsageSuccess: 'Contador de consumo restablecido', + proxy: 'Proxy', + removeProvider: 'Quitar', + noProviders: 'No hay proveedores de búsqueda configurados', + test: 'Probar', + testDefaultQuery: 'Principales acontecimientos mundiales de este año', + testing: 'Buscando...', + testResultTitle: 'Resultados de la búsqueda', + testResultProvider: 'Proveedor', + testNoResults: 'No se encontraron resultados', + }, + site: { + title: 'Ajustes del sitio', + description: 'Personaliza la marca del sitio', + backendMode: 'Modo solo administración', + backendModeDescription: + 'Desactiva el registro de usuarios, el sitio público y las funciones de autoservicio. Solo el administrador puede iniciar sesión y gestionar la plataforma.', + siteName: 'Nombre del sitio', + siteNamePlaceholder: 'Sub2API', + siteNameHint: 'Se muestra en los correos y en los títulos de las páginas', + siteSubtitle: 'Subtítulo del sitio', + siteSubtitlePlaceholder: 'Plataforma de conversión de suscripción a API', + siteSubtitleHint: 'Se muestra en las páginas de acceso y registro', + apiBaseUrl: 'URL base de la API', + apiBaseUrlPlaceholder: 'https://api.ejemplo.com', + apiBaseUrlHint: + 'Se usa en «Usar la clave», «Importar a CC Switch» y en las sugerencias de URL de retorno. Déjalo vacío para usar la URL del sitio actual.', + tablePreferencesTitle: 'Preferencias globales de las tablas', + tablePreferencesDescription: 'Configura el comportamiento de paginación por defecto de las tablas', + tableDefaultPageSize: 'Filas por página por defecto', + tableDefaultPageSizeHint: 'Debe ser un número entero entre 5 y 1000', + tablePageSizeOptions: 'Opciones de filas por página', + tablePageSizeOptionsPlaceholder: '10, 20, 50, 100', + tablePageSizeOptionsHint: 'Separa con comas números enteros entre 5 y 1000; al guardar se quitan los duplicados y se ordenan', + tableDefaultPageSizeRangeError: 'Las filas por página por defecto deben estar entre {min} y {max}', + tablePageSizeOptionsFormatError: 'Formato de opciones no válido. Introduce números enteros separados por comas entre {min} y {max}', + customEndpoints: { + title: 'Endpoints personalizados', + description: 'Añade más URLs de endpoint de la API para que los usuarios las copien rápido desde la página de claves API', + itemLabel: 'Endpoint n.º {n}', + name: 'Nombre', + namePlaceholder: 'p. ej. Compatible con OpenAI', + endpointUrl: 'URL del endpoint', + endpointUrlPlaceholder: 'https://api2.ejemplo.com', + descriptionLabel: 'Descripción', + descriptionPlaceholder: 'p. ej. Admite peticiones en formato OpenAI', + add: 'Añadir un endpoint', + }, + contactInfo: 'Datos de contacto', + contactInfoPlaceholder: 'p. ej. WhatsApp: 55 1234 5678', + contactInfoHint: 'Datos de contacto de soporte, se muestran en la página de canje, el perfil, etc.', + docUrl: 'URL de la documentación', + docUrlPlaceholder: 'https://docs.ejemplo.com', + docUrlHint: 'Enlace a tu sitio de documentación. Déjalo vacío para ocultar el enlace.', + siteLogo: 'Logotipo del sitio', + uploadImage: 'Subir una imagen', + remove: 'Quitar', + logoHint: 'PNG, JPG o SVG. Máximo 300 KB. Recomendado: imagen cuadrada de 80x80 px.', + logoSizeError: 'La imagen supera el límite de 300 KB ({size} KB)', + logoTypeError: 'Selecciona un archivo de imagen', + logoReadError: 'No se pudo leer el archivo de imagen', + homeContent: 'Contenido de la página de inicio', + homeContentPlaceholder: 'Introduce el contenido personalizado de la página de inicio. Admite Markdown y HTML. Si escribes una URL, se mostrará como un iframe.', + homeContentHint: 'Personaliza el contenido de la página de inicio. Admite Markdown y HTML. Si escribes una URL (que empiece por http:// o https://), se usará como origen de un iframe para incrustar una página externa. Cuando se rellena, deja de mostrarse la información de estado por defecto.', + homeContentIframeWarning: '⚠️ Nota sobre el modo iframe: algunos sitios web tienen políticas de seguridad X-Frame-Options o CSP que impiden incrustarlos en un iframe. Si la página sale en blanco o da error, comprueba que el sitio de destino permita la incrustación, o plantéate usar el modo HTML para crear tu propio contenido.', + compactHome: 'Página de inicio compacta', + compactHomeHint: 'Muestra una página de identidad del sitio sobria cuando no hay contenido de inicio personalizado.', + hideCcsImportButton: 'Ocultar el botón de importar a CCS', + hideCcsImportButtonHint: 'Si se activa, el botón «Importar a CCS» se ocultará en la página de claves API' + }, + purchase: { + title: 'Página de recarga y suscripción', + description: 'Muestra una entrada «Recargar / Suscripción» en la barra lateral y abre la URL configurada en un iframe', + enabled: 'Mostrar el acceso a recarga y suscripción', + enabledHint: 'Solo se muestra en el modo estándar (no en el modo simple)', + url: 'URL de recarga y suscripción', + urlPlaceholder: 'https://ejemplo.com/comprar', + urlHint: 'Tiene que ser una URL http(s) absoluta', + iframeWarning: + '⚠️ Nota sobre el iframe: algunos sitios bloquean la incrustación con X-Frame-Options o CSP (frame-ancestors). Si la página sale en blanco, ofrece una alternativa de «Abrir en una pestaña nueva».', + integrationDoc: 'Documentación de integración de pagos', + integrationDocHint: 'Cubre las especificaciones de los endpoints, la idempotencia y ejemplos de código' + }, + soraClient: { + title: 'Cliente de Sora', + description: 'Controla si se muestra el acceso al cliente de Sora en la barra lateral', + enabled: 'Activar el cliente de Sora', + enabledHint: 'Si se activa, aparecerá la entrada de Sora en la barra lateral para que los usuarios accedan a sus funciones' + }, + customMenu: { + title: 'Páginas de menú personalizadas', + description: 'Añade páginas propias en iframe a la navegación lateral. Cada página puede ser visible para los usuarios normales o para los administradores.', + itemLabel: 'Elemento de menú n.º {n}', + name: 'Nombre del menú', + namePlaceholder: 'p. ej. Centro de ayuda', + url: 'URL de la página', + urlPlaceholder: 'https://ejemplo.com/pagina', + iconSvg: 'Icono SVG', + iconSvgPlaceholder: '...', + iconPreview: 'Vista previa del icono', + uploadSvg: 'Subir un SVG', + removeSvg: 'Quitar', + visibility: 'Visible para', + visibilityUser: 'Usuarios normales', + visibilityAdmin: 'Administradores', + add: 'Añadir un elemento de menú', + remove: 'Quitar', + moveUp: 'Subir', + moveDown: 'Bajar', + }, + payment: { + title: 'Ajustes de pago', + description: 'Configura las opciones del sistema de pagos', + configGuide: 'Guía de configuración', + enabled: 'Activar los pagos', + enabledHint: 'Activa o desactiva el sistema de pagos', + enabledPaymentTypes: 'Proveedores activados', + enabledPaymentTypesHint: 'Desactivar un proveedor también desactiva sus instancias.', + findProvider: '¿Buscas un proveedor de EasyPay adecuado?', + minAmount: 'Importe mínimo', + maxAmount: 'Importe máximo', + dailyLimit: 'Límite diario', + balanceRechargeMultiplier: 'Multiplicador de recarga de saldo', + balanceRechargeMultiplierHint: 'Cuánto saldo en USD recibe el usuario por cada 1 CNY pagado', + balanceRechargePreview: 'Vista previa: 1 CNY = {usd} USD', + subscriptionUsdToCnyRate: 'Tipo de cambio USD a CNY de las suscripciones', + subscriptionUsdToCnyRateHint: + 'CNY que se cobran por cada 1 USD del precio del plan en los canales en CNY (p. ej. 7,15). 0 o vacío = desactivado, el precio del plan se cobra tal cual. Si se activa, todos los precios de los planes deben estar en USD', + subscriptionUsdToCnyRateDisabled: 'Desactivado (el precio se cobra tal cual)', + rechargeFeeRate: 'Porcentaje de comisión por recarga', + rechargeFeeRateHint: 'Porcentaje de comisión que se cobra sobre el importe de la recarga, 0 significa sin comisión', + rechargeFeePreview: 'Vista previa: recarga de 100, comisión {fee}', + orderTimeout: 'Caducidad del pedido', + orderTimeoutHint: 'En minutos, mínimo 1', + maxPendingOrders: 'Máximo de pedidos pendientes', + cancelRateLimit: 'Limitar las cancelaciones', + cancelRateLimitHint: 'Si se activa, los usuarios que superen el límite de cancelaciones dentro de la ventana no podrán crear pedidos nuevos', + cancelRateLimitEvery: 'Cada', + cancelRateLimitAllowMax: 'permitir como máximo', + cancelRateLimitTimes: 'cancelaciones', + cancelRateLimitWindow: 'Ventana', + cancelRateLimitUnit: 'Unidad', + cancelRateLimitMax: 'Máximo de cancelaciones', + cancelRateLimitUnitMinute: 'Minutos', + cancelRateLimitUnitHour: 'Horas', + cancelRateLimitUnitDay: 'Días', + cancelRateLimitWindowMode: 'Modo de ventana', + cancelRateLimitWindowModeRolling: 'Móvil', + cancelRateLimitWindowModeFixed: 'Fija', + alipayForceQRCode: 'Forzar el código QR de Alipay', + alipayForceQRCodeHint: 'Si se activa, los usuarios de Alipay en el móvil siempre ven un código QR en lugar de ir a la página de pago móvil', + alipayMobilePrecreateDeepLink: 'Entrega móvil de Alipay con precreate', + alipayMobilePrecreateDeepLinkHint: 'Usa el precreate oficial de Alipay en el móvil, abre la app de Alipay y solo muestra el QR dinámico si la entrega falla. Tiene prioridad sobre «Forzar el código QR de Alipay»', + helpText: 'Texto de ayuda', + helpImageUrl: 'URL de la imagen de ayuda', + manageProviders: 'Gestionar proveedores', + balancePaymentDisabled: 'Desactivar la recarga de saldo', + noLimit: 'Vacío = sin límite', + helpImage: 'Imagen de ayuda', + helpImagePlaceholder: 'Sube una imagen o introduce su URL', + helpTextPlaceholder: 'Introduce el texto de ayuda...', + providerEasypay: 'EasyPay', + providerAlipay: 'Alipay (directo)', + providerWxpay: 'WeChat Pay (directo)', + providerStripe: 'Stripe', + providerAirwallex: 'Airwallex', + typeDisabled: 'tipo desactivado', + enableTypesFirst: 'Activa primero al menos un método de pago de arriba', + easypayRedirect: 'Redirección', + paymentMode: 'Modo de pago', + modeRedirect: 'Redirección', + modeQRCode: 'Código QR', + modePopup: 'Ventana emergente', + validationNameRequired: 'El nombre del proveedor es obligatorio', + validationTypesRequired: 'Selecciona al menos un método de pago admitido', + validationFieldRequired: '{field} es obligatorio', + validationEasyPayCustomMethodRequired: 'Cada método personalizado de EasyPay necesita un tipo de pago y un tipo del proveedor', + validationEasyPayCustomMethodTypeInvalid: 'Los tipos de pago personalizados de EasyPay solo pueden llevar minúsculas, dígitos, guiones bajos y guiones', + validationEasyPayCustomMethodUpstreamTypeInvalid: 'Los tipos del proveedor de EasyPay solo pueden llevar minúsculas, dígitos, guiones bajos y guiones', + validationEasyPayCustomMethodReserved: 'Los tipos de pago personalizados de EasyPay no pueden usar los integrados alipay ni wxpay', + validationEasyPayCustomMethodPrefixReserved: 'Los tipos de pago personalizados de EasyPay no pueden empezar por alipay ni wxpay', + validationEasyPayCustomMethodDuplicate: 'Los tipos de pago personalizados de EasyPay deben ser únicos', + field_apiBase: 'URL base de la API', + field_notifyUrl: 'URL de notificación', + field_returnUrl: 'URL de retorno', + callbackBaseUrl: 'URL base de retorno', + field_privateKey: 'Clave privada', + field_publicKey: 'Clave pública', + field_mpAppId: 'App ID de la cuenta oficial', + field_mchId: 'ID de comercio', + field_apiV3Key: 'Clave de la API v3', + field_publicKeyId: 'ID de la clave pública', + field_certSerial: 'Número de serie del certificado', + field_h5AppName: 'Nombre de la app H5', + field_h5AppUrl: 'URL de la app H5', + wxpayConfigHint: 'WeChat Pay normalmente solo necesita el App ID. Rellena el App ID de la cuenta oficial, el nombre y la URL de la app H5 solo si tu cuenta oficial o tu flujo H5 lo requieren expresamente.', + wxpayAdvancedOptions: 'Opciones avanzadas de WeChat Pay', + field_secretKey: 'Clave secreta', + field_clientId: 'Client ID', + field_apiKey: 'Clave API', + field_publishableKey: 'Clave publicable', + field_webhookSecret: 'Secreto del webhook', + field_countryCode: 'Código de país o región', + field_currency: 'Moneda del pago', + field_accountId: 'ID de cuenta de Airwallex', + field_airwallexApiBaseHint: 'Debe coincidir con el entorno de la clave API: usa https://api-demo.airwallex.com/api/v1 para claves de prueba y https://api.airwallex.com/api/v1 para las de producción. Mezclar entornos devuelve credentials_invalid o Access Denied.', + field_paymentCurrencyHint: 'Por defecto es CNY. Stripe y Airwallex pueden elegir HKD, USD u otra moneda de la lista que admita la cuenta; WeChat Pay, Alipay y EasyPay se quedan en CNY.', + field_accountIdHint: 'Déjalo vacío salvo que uses varias cuentas, una clave a nivel de organización o pagos con cuentas conectadas. Una clave API limitada a una sola cuenta usa esa cuenta por defecto.', + field_cid: 'ID de canal', + field_cidAlipay: 'ID de canal de Alipay', + field_cidWxpay: 'ID de canal de WeChat', + easypayCustomMethods: 'Métodos personalizados de EasyPay', + easypayCustomMethodsHint: 'Añade los métodos propios del proveedor que admite este endpoint de EasyPay. El tipo de pago se guarda en los pedidos de Sub2API; el tipo del proveedor se envía como type de EasyPay.', + addCustomMethod: 'Añadir un método', + customMethodType: 'Tipo de pago', + customMethodUpstreamType: 'Tipo del proveedor', + customMethodDisplayName: 'Nombre visible', + customMethodDisplayNamePlaceholder: 'p. ej. Tarjeta de crédito', + stripeWebhookHint: 'Configura la siguiente URL como endpoint de webhook en el panel de Stripe:', + stripeWebhookApiVersionHint: 'Pon en este endpoint de webhook la misma versión de API que el SDK de Stripe integrado. Recomendada: {version}. Si no coinciden puede haber errores al interpretar el webhook.', + airwallexWebhookHint: 'Configura la siguiente URL como endpoint de webhook en Airwallex. Selecciona al menos Payment Intent -> Succeeded (payment_intent.succeeded) y, a poder ser, también Payment Intent -> Cancelled (payment_intent.cancelled). Usa la versión de API por defecto de la cuenta o la última estable.', + airwallexGuideSummary: 'Al crear una clave API limitada en Airwallex, selecciona Lectura y escritura en Payment Acceptance dentro de los permisos de la cuenta.', + airwallexGuideNote: 'No concedas permisos ajenos como Spend, Payouts, Transfers, Funds Splits o POS Terminals salvo que los necesites expresamente. Para los webhooks, selecciona al menos payment_intent.succeeded y, a poder ser, también payment_intent.cancelled, y usa la versión de API por defecto de la cuenta o la última estable.', + limitsTitle: 'Límites', + limitSingleMin: 'Mínimo por pedido', + limitSingleMax: 'Máximo por pedido', + limitDaily: 'Límite diario', + limitsHint: 'Todo vacío = usar la configuración global; parcialmente relleno = vacío significa sin límite', + limitsUseGlobal: 'Usar el global', + limitsNoLimit: 'Sin límite', + productNamePrefix: 'Prefijo del nombre del producto', + productNameSuffix: 'Sufijo del nombre del producto', + preview: 'Vista previa', + loadBalanceStrategy: 'Estrategia de reparto de carga', + strategyRoundRobin: 'Por turnos', + strategyLeastAmount: 'Menor importe diario', + providerManagement: 'Gestión de proveedores', + providerManagementDesc: 'Gestiona las instancias de proveedores de pago', + createProvider: 'Añadir un proveedor', + editProvider: 'Editar el proveedor', + deleteProvider: 'Eliminar el proveedor', + deleteProviderConfirm: '¿Seguro que quieres eliminar este proveedor?', + providerName: 'Nombre del proveedor', + providerKey: 'Tipo de proveedor', + selectProviderKey: 'Selecciona el tipo de proveedor', + providerConfig: 'Credenciales', + paymentGuideTrigger: 'Ver la guía de pagos', + guideOpenLabel: 'Activar: ', + guideCallLabel: 'Llamada: ', + guideFallbackLabel: 'Alternativa: ', + alipayGuideSummary: 'En escritorio se prefiere el QR con precreate y se recurre a la caja; en móvil se prefiere el pago WAP.', + alipayGuideFaceToFaceTitle: 'Pago presencial / QR', + alipayGuideFaceToFaceOpen: 'Activa la capacidad de pago presencial o con QR.', + alipayGuideFaceToFaceCall: 'Los pedidos de escritorio llaman primero a alipay.trade.precreate y muestran el QR directamente.', + alipayGuideFaceToFaceFallback: 'Si no está disponible o falla, el flujo pasa automáticamente a la caja web.', + alipayGuidePagePayTitle: 'Pago web', + alipayGuidePagePayOpen: 'Activa el pago web.', + alipayGuidePagePayCall: 'Si el pago presencial no está disponible en escritorio, el flujo llama a alipay.trade.page.pay y sigue mostrando el enlace devuelto como código QR.', + alipayGuidePagePayFallback: 'El enlace de la caja sigue disponible para que los usuarios puedan volver a abrir la página de pago a mano.', + alipayGuideWapTitle: 'Pago WAP', + alipayGuideWapOpen: 'Activa el pago web para móvil.', + alipayGuideWapCall: 'Los pedidos desde el móvil llaman primero a alipay.trade.wap.pay y saltan a la caja de Alipay.', + alipayGuideWapFallback: 'Si el pago móvil no está disponible o falla, el frontend cambia al pago con QR y muestra un aviso.', + wxpayGuideSummary: 'En escritorio se prefiere el QR nativo; en móvil se enruta a JSAPI o H5 según el navegador.', + wxpayGuideNote: 'El formulario actual usa por defecto un único App ID compartido, que encaja con el montaje habitual de web, móvil y cuenta oficial con un mismo titular.', + wxpayGuideNativeTitle: 'Pago nativo / QR', + wxpayGuideNativeOpen: 'Activa la capacidad de pago nativo o con QR.', + wxpayGuideNativeCall: 'Los pedidos de escritorio usan Native por defecto y el frontend muestra el contenido del QR.', + wxpayGuideNativeFallback: 'Los flujos móviles también recurren aquí cuando no se puede usar JSAPI ni H5.', + wxpayGuideJsapiTitle: 'JSAPI / Cuenta oficial', + wxpayGuideJsapiOpen: 'Activa el pago de cuenta oficial y asegúrate de que el navegador esté dentro de WeChat con un OpenID disponible.', + wxpayGuideJsapiCall: 'Dentro de WeChat, la app llama a JSAPI tras la autorización y lanza WeChat Pay directamente.', + wxpayGuideJsapiFallback: 'Si falta configuración, el puente no está disponible o el lanzamiento falla, el flujo recurre al pago con QR.', + wxpayGuideH5Title: 'Pago H5', + wxpayGuideH5Open: 'Activa el pago H5.', + wxpayGuideH5Call: 'En navegadores móviles fuera de WeChat, la app llama al pago H5 cuando hay una IP de cliente disponible.', + wxpayGuideH5Fallback: 'Si H5 no está disponible o falla la creación del pedido, el flujo recurre al pago con QR.', + noProviders: 'No hay instancias de proveedor configuradas', + supportedTypes: 'Métodos de pago admitidos', + supportedTypesHint: 'Separados por comas, p. ej. alipay,wxpay', + refundEnabled: 'Permitir reembolsos', + allowUserRefund: 'Permitir que el usuario pida el reembolso', + enableConflict: '{method} ya tiene una instancia de proveedor activa: {provider}. Desactiva la instancia existente antes de cambiar.', + }, + balanceNotify: { + title: 'Aviso de saldo bajo', + description: 'Envía un aviso por correo cuando el saldo del usuario baje del umbral', + enabled: 'Activar el aviso de saldo bajo', + threshold: 'Umbral por defecto', + thresholdHint: 'Se usa cuando el usuario no ha definido un valor propio', + thresholdPlaceholder: 'Introduce el importe', + rechargeUrl: 'URL de la página de recarga', + rechargeUrlPlaceholder: 'https://ejemplo.com/pago', + rechargeUrlHint: 'Si lo rellenas, en el correo aparecerá un botón para recargar', + }, + quotaNotify: { + title: 'Aviso de cuota de las cuentas', + description: 'Avisa a los administradores cuando el consumo de la cuota de una cuenta alcanza el umbral de alerta', + enabled: 'Activar el aviso de cuota de las cuentas', + emails: 'Correos de aviso', + emailsHint: 'Déjalo vacío para desactivar los avisos', + addEmail: 'Añadir un correo', + emailPlaceholder: 'Introduce la dirección de correo', + }, + subscriptionExpiryNotify: { + title: 'Recordatorio de caducidad de la suscripción', + description: 'Controla si los usuarios reciben correos recordándoles que su suscripción va a caducar.', + enabled: 'Activar el recordatorio de caducidad', + enabledHint: 'Si se activa, el sistema envía recordatorios 7, 3 y 1 día antes de caducar.' + }, + smtp: { + title: 'Ajustes de SMTP', + description: 'Configura el envío de correos para los códigos de verificación', + testConnection: 'Probar la conexión', + testing: 'Probando...', + host: 'Servidor SMTP', + hostPlaceholder: 'smtp.gmail.com', + port: 'Puerto SMTP', + portPlaceholder: '587', + username: 'Usuario SMTP', + usernamePlaceholder: "tu-correo{'@'}gmail.com", + password: 'Contraseña SMTP', + passwordPlaceholder: '********', + passwordHint: 'Déjalo vacío para conservar la contraseña actual', + passwordConfiguredPlaceholder: '********', + passwordConfiguredHint: 'La contraseña está configurada. Déjalo vacío para conservarla.', + fromEmail: 'Correo del remitente', + fromEmailPlaceholder: "noreply{'@'}ejemplo.com", + fromName: 'Nombre del remitente', + fromNamePlaceholder: 'Sub2API', + useTls: 'Usar TLS', + useTlsHint: 'Activa el cifrado TLS en la conexión SMTP' + }, + testEmail: { + title: 'Enviar un correo de prueba', + description: 'Envía un correo de prueba para comprobar tu configuración de SMTP', + recipientEmail: 'Correo del destinatario', + recipientEmailPlaceholder: "prueba{'@'}ejemplo.com", + sendTestEmail: 'Enviar el correo de prueba', + sending: 'Enviando...', + enterRecipientHint: 'Introduce la dirección de correo del destinatario' + }, + emailTemplates: { + title: 'Plantillas de correo', + description: 'Personaliza el asunto y el contenido HTML de los correos de notificación para cada evento e idioma.', + event: 'Evento', + locale: 'Idioma', + localeEn: 'Inglés', + localeZh: 'Chino', + subject: 'Asunto', + subjectPlaceholder: 'Introduce el asunto del correo', + html: 'Plantilla HTML', + htmlPlaceholder: 'Edita la plantilla HTML del correo', + placeholders: 'Marcadores disponibles', + placeholdersHelp: 'Pulsa un marcador para copiarlo. El servidor sustituye estos valores al enviar los correos.', + livePreview: 'Vista previa en vivo', + previewSecurityHint: 'El HTML de la vista previa lo genera el endpoint de previsualización del servidor y se muestra en un iframe aislado con los scripts desactivados.', + preview: 'Previsualizar / Actualizar', + previewing: 'Generando la vista previa...', + save: 'Guardar la plantilla', + saving: 'Guardando...', + restoreOfficial: 'Restaurar la oficial', + restoring: 'Restaurando...', + restoreConfirm: '¿Restaurar la plantilla oficial de este evento e idioma? Se sustituirá tu versión personalizada.', + restoreSuccess: 'Plantilla oficial restaurada', + saveSuccess: 'Plantilla de correo guardada', + placeholderCopied: 'Marcador copiado', + validationRequired: 'El asunto y la plantilla HTML son obligatorios', + empty: 'Todavía no hay eventos ni idiomas de plantillas de correo disponibles.', + noPreview: 'Actualiza la vista previa para ver el asunto del correo generado.', + customized: 'Personalizada' + }, + opsMonitoring: { + title: 'Monitorización de operaciones', + description: 'Activa la monitorización de operaciones para resolver problemas y ver el estado del sistema', + disabled: 'La monitorización de operaciones está desactivada', + enabled: 'Activar la monitorización de operaciones', + enabledHint: 'Activa el módulo de monitorización de operaciones (solo para administradores)', + realtimeEnabled: 'Activar la monitorización en tiempo real', + realtimeEnabledHint: 'Activa el envío en tiempo real de QPS y métricas (WebSocket)', + queryMode: 'Modo de consulta por defecto', + queryModeHint: 'Modo de consulta por defecto del panel de operaciones (automático, en bruto o preagregado)', + queryModeAuto: 'Automático (recomendado)', + queryModeRaw: 'En bruto (más exacto, más lento)', + queryModePreagg: 'Preagregado (el más rápido, requiere agregación)', + metricsInterval: 'Intervalo de recogida de métricas (segundos)', + metricsIntervalHint: 'Cada cuánto se recogen las métricas de sistema y peticiones (de 60 a 3600 segundos)' + }, + adminApiKey: { + title: 'Clave API de administrador', + description: 'Clave API global para integrar sistemas externos con acceso total de administrador', + notConfigured: 'La clave API de administrador no está configurada', + configured: 'La clave API de administrador está activa', + currentKey: 'Clave actual', + regenerate: 'Regenerar', + regenerating: 'Regenerando...', + delete: 'Eliminar', + deleting: 'Eliminando...', + create: 'Crear la clave', + creating: 'Creando...', + regenerateConfirm: '¿Seguro? La clave actual quedará invalidada de inmediato.', + deleteConfirm: + '¿Seguro que quieres eliminar la clave API de administrador? Las integraciones externas dejarán de funcionar.', + keyGenerated: 'Se generó una clave API de administrador nueva', + keyDeleted: 'Clave API de administrador eliminada', + copyKey: 'Copiar la clave', + keyCopied: 'Clave copiada al portapapeles', + keyWarning: 'Esta clave solo se muestra una vez. Cópiala ahora.', + securityWarning: 'Aviso: esta clave da acceso total de administrador. Guárdala en un lugar seguro.', + usage: 'Uso: añádela a la cabecera de la petición - x-api-key: ' + }, + soraS3: { + title: 'Almacenamiento de Sora', + description: 'Gestiona los perfiles de almacenamiento de los archivos de Sora, con S3 y Google Drive', + newProfile: 'Nuevo perfil', + reloadProfiles: 'Recargar los perfiles', + empty: 'Todavía no hay perfiles de almacenamiento, crea uno primero', + createTitle: 'Crear un perfil de almacenamiento', + editTitle: 'Editar el perfil de almacenamiento', + selectProvider: 'Selecciona el tipo de almacenamiento', + providerS3Desc: 'Almacenamiento de objetos compatible con S3', + providerGDriveDesc: 'Almacenamiento en la nube de Google Drive', + profileID: 'ID del perfil', + profileName: 'Nombre del perfil', + setActive: 'Marcar como activo al crearlo', + saveProfile: 'Guardar el perfil', + activateProfile: 'Activar', + profileCreated: 'Perfil de almacenamiento creado', + profileSaved: 'Perfil de almacenamiento guardado', + profileDeleted: 'Perfil de almacenamiento eliminado', + profileActivated: 'Se cambió el perfil de almacenamiento activo', + profileIDRequired: 'El ID del perfil es obligatorio', + profileNameRequired: 'El nombre del perfil es obligatorio', + profileSelectRequired: 'Selecciona primero un perfil', + endpointRequired: 'El endpoint de S3 es obligatorio cuando está activado', + bucketRequired: 'El bucket es obligatorio cuando está activado', + accessKeyRequired: 'El Access Key ID es obligatorio cuando está activado', + deleteConfirm: '¿Eliminar el perfil de almacenamiento {profileID}?', + columns: { + profile: 'Perfil', + profileId: 'ID del perfil', + name: 'Nombre', + provider: 'Tipo', + active: 'Activo', + endpoint: 'Endpoint', + bucket: 'Bucket', + storagePath: 'Ruta de almacenamiento', + capacityUsage: 'Capacidad / Usado', + capacityUnlimited: 'Ilimitada', + videoCount: 'Vídeos', + videoCompleted: 'completados', + videoInProgress: 'en curso', + quota: 'Cuota por defecto', + updatedAt: 'Actualizado el', + actions: 'Acciones', + rootFolder: 'Carpeta raíz', + testInTable: 'Probar', + testingInTable: 'Probando...', + testTimeout: 'La prueba agotó el tiempo (15 s)' + }, + enabled: 'Activar el almacenamiento', + enabledHint: 'Si se activa, los archivos que genere Sora se subirán automáticamente', + endpoint: 'Endpoint de S3', + region: 'Región', + bucket: 'Bucket', + prefix: 'Prefijo de los objetos', + accessKeyId: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + secretConfigured: '(Configurada, déjalo vacío para conservarla)', + cdnUrl: 'URL de la CDN', + cdnUrlHint: 'Opcional. Si se configura, los archivos se sirven a través de la URL de la CDN', + forcePathStyle: 'Forzar el estilo de ruta', + defaultQuota: 'Cuota de almacenamiento por defecto', + defaultQuotaHint: 'Cuota por defecto cuando no se define a nivel de usuario o de grupo. 0 significa ilimitada', + testConnection: 'Probar la conexión', + testing: 'Probando...', + testSuccess: 'La prueba de conexión salió bien', + testFailed: 'Falló la prueba de conexión', + saved: 'Ajustes de almacenamiento guardados correctamente', + saveFailed: 'No se pudieron guardar los ajustes de almacenamiento', + gdrive: { + authType: 'Método de autenticación', + serviceAccount: 'Cuenta de servicio', + clientId: 'Client ID', + clientSecret: 'Client Secret', + clientSecretConfigured: '(Configurado, déjalo vacío para conservarlo)', + refreshToken: 'Refresh Token', + refreshTokenConfigured: '(Configurado, déjalo vacío para conservarlo)', + serviceAccountJson: 'JSON de la cuenta de servicio', + serviceAccountConfigured: '(Configurado, déjalo vacío para conservarlo)', + folderId: 'ID de la carpeta (opcional)', + authorize: 'Autorizar Google Drive', + authorizeHint: 'Consigue el Refresh Token mediante OAuth2', + oauthFieldsRequired: 'Rellena primero el Client ID y el Client Secret', + oauthSuccess: 'Autorización de Google Drive correcta', + oauthFailed: 'Falló la autorización de Google Drive', + closeWindow: 'Esta ventana se cerrará sola', + processing: 'Procesando la autorización...', + testStorage: 'Probar el almacenamiento', + testSuccess: 'La prueba de almacenamiento en Google Drive pasó (subida, acceso y borrado correctos)', + testFailed: 'Falló la prueba de almacenamiento en Google Drive' + } + }, + overloadCooldown: { + title: 'Enfriamiento por sobrecarga (529)', + description: 'Configura cómo se pausa la planificación de una cuenta cuando el proveedor devuelve un 529 (sobrecargado)', + enabled: 'Activar el enfriamiento por sobrecarga', + enabledHint: 'Pausa la planificación de la cuenta ante errores 529 y la recupera sola tras el enfriamiento', + cooldownMinutes: 'Duración del enfriamiento (minutos)', + cooldownMinutesHint: 'Tiempo que se pausa la planificación de la cuenta (de 1 a 120 minutos)', + saved: 'Ajustes de enfriamiento por sobrecarga guardados', + saveFailed: 'No se pudieron guardar los ajustes de enfriamiento por sobrecarga' + }, + rateLimit429Cooldown: { + title: 'Enfriamiento por defecto ante 429', + description: 'Configura el enfriamiento por defecto de la cuenta cuando el proveedor devuelve un 429 sin indicar cuándo se reinicia', + enabled: 'Activar el enfriamiento por defecto ante 429', + enabledHint: 'Pausa la planificación de la cuenta cuando un 429 no trae hora de reinicio, y la recupera sola tras el enfriamiento', + cooldownSeconds: 'Duración del enfriamiento (segundos)', + cooldownSecondsHint: 'Duración del enfriamiento por defecto (de 1 a 7200 segundos); las horas de reinicio que indique el proveedor siguen teniendo prioridad', + saved: 'Ajustes de enfriamiento por defecto ante 429 guardados', + saveFailed: 'No se pudieron guardar los ajustes de enfriamiento por defecto ante 429' + }, + streamTimeout: { + title: 'Gestión de tiempos de espera en el flujo', + description: 'Configura qué hacer con la cuenta cuando la respuesta del proveedor agota el tiempo', + enabled: 'Activar la gestión de tiempos de espera', + enabledHint: 'Gestiona automáticamente las cuentas problemáticas cuando el proveedor agota el tiempo', + timeoutSeconds: 'Umbral de tiempo (segundos)', + timeoutSecondsHint: 'Si el intervalo entre datos del flujo supera este tiempo, se considera agotado (de 30 a 300 s)', + action: 'Acción', + actionTempUnsched: 'Marcar como temporalmente no planificable', + actionError: 'Marcar como error', + actionNone: 'No hacer nada', + actionHint: 'Qué hacer con la cuenta cuando se agota el tiempo', + tempUnschedMinutes: 'Duración de la pausa (minutos)', + tempUnschedMinutesHint: 'Cuánto dura el estado temporalmente no planificable (de 1 a 60 minutos)', + thresholdCount: 'Umbral para actuar (número)', + thresholdCountHint: 'Cuántos tiempos agotados hacen falta para actuar (de 1 a 10)', + thresholdWindowMinutes: 'Ventana del umbral (minutos)', + thresholdWindowMinutesHint: 'Ventana de tiempo en la que se cuentan los tiempos agotados (de 1 a 60 minutos)', + saved: 'Ajustes de tiempo de espera del flujo guardados', + saveFailed: 'No se pudieron guardar los ajustes de tiempo de espera del flujo' + }, + rectifier: { + title: 'Corrector de peticiones', + description: 'Corrige automáticamente los parámetros de la petición y reintenta cuando el proveedor devuelve ciertos errores', + enabled: 'Activar el corrector de peticiones', + enabledHint: 'Interruptor principal: desactivarlo apaga todas las correcciones', + thinkingSignature: 'Corrector de firma de razonamiento', + thinkingSignatureHint: 'Quita automáticamente las firmas y reintenta cuando el proveedor devuelve errores de validación de la firma de los bloques de razonamiento', + thinkingBudget: 'Corrector de presupuesto de razonamiento', + thinkingBudgetHint: 'Pone automáticamente el presupuesto en 32000 y reintenta cuando el proveedor devuelve un error por la restricción de budget_tokens (≥1024)', + apikeySignature: 'Corrector de firma para claves API', + apikeySignatureHint: + 'Quita automáticamente las firmas y reintenta cuando las cuentas con clave API reciben errores relacionados con la firma (los patrones integrados siempre se aplican)', + apikeyPatterns: 'Patrones de coincidencia personalizados', + apikeyPatternsHint: + 'Palabras clave adicionales que se comparan con el cuerpo de la respuesta (sin distinguir mayúsculas). Los patrones integrados siempre se aplican; usa estos como complemento.', + apikeyPatternPlaceholder: 'p. ej. thinking_error', + addPattern: 'Añadir un patrón', + saved: 'Ajustes del corrector guardados', + saveFailed: 'No se pudieron guardar los ajustes del corrector' + }, + betaPolicy: { + title: 'Política de funciones beta', + description: 'Cómo tratar las funciones beta al configurar el reenvío de peticiones a la API de Anthropic. Solo se aplica al endpoint /v1/messages.', + action: 'Acción', + actionPass: 'Pasar (transparente)', + actionFilter: 'Filtrar (quitar)', + actionBlock: 'Bloquear (rechazar)', + scope: 'Alcance', + scopeAll: 'Todas las cuentas', + scopeOAuth: 'Solo OAuth', + scopeAPIKey: 'Solo clave API', + scopeBedrock: 'Solo Bedrock', + errorMessage: 'Mensaje de error', + errorMessagePlaceholder: 'Mensaje de error personalizado al bloquear', + errorMessageHint: 'Déjalo vacío para el mensaje por defecto', + saved: 'Ajustes de la política beta guardados', + saveFailed: 'No se pudieron guardar los ajustes de la política beta', + modelWhitelist: 'Lista de modelos permitidos', + modelWhitelistHint: 'Déjalo vacío para aplicarlo a todos los modelos. Admite coincidencia exacta y comodín de prefijo (p. ej. claude-opus-*)', + modelPatternPlaceholder: 'p. ej. claude-opus-* o claude-opus-4-6', + addModelPattern: 'Añadir un patrón de modelo', + removePattern: 'Quitar', + fallbackAction: 'Acción alternativa', + fallbackActionHint: 'Qué hacer con los modelos que no estén en la lista', + fallbackErrorMessagePlaceholder: 'Mensaje de error personalizado cuando se bloquean modelos fuera de la lista', + quickPresets: 'Valores rápidos', + presetOpusOnly: 'Solo Opus para 1M', + presetOpusOnlyDesc: 'Pasar en Opus, filtrar el resto', + commonPatterns: 'Patrones habituales' + }, + openaiFastPolicy: { + title: 'Política de fast/flex de OpenAI', + description: 'Intercepta, filtra o deja pasar las peticiones fast (prioritarias) y flex de OpenAI según el campo service_tier del cuerpo. Solo se aplica a la pasarela de OpenAI.', + empty: 'No hay reglas configuradas. Pulsa el botón de abajo para añadir una.', + ruleHeader: 'Regla n.º {index}', + removeRule: 'Quitar la regla', + addRule: 'Añadir una regla', + saveHint: 'Se guarda junto con los ajustes del sistema (pulsa el botón Guardar del final de la página).', + serviceTier: 'Coincidencia de service_tier', + tierAll: 'Todos los niveles', + tierPriority: 'priority (rápido)', + tierFlex: 'flex', + action: 'Acción', + actionPass: 'Pasar (conservar service_tier)', + actionFilter: 'Filtrar (quitar service_tier)', + actionForcePriority: 'Forzar priority (rápido)', + actionBlock: 'Bloquear (rechazar la petición)', + scope: 'Alcance', + scopeAll: 'Todas las cuentas', + scopeOAuth: 'Solo OAuth', + scopeAPIKey: 'Solo clave API', + scopeBedrock: 'Solo Bedrock', + userIds: 'Usuarios concretos', + userIdsHint: 'Escribe parte del correo de un usuario para buscarlo. Déjalo vacío para aplicarlo a todos los usuarios de Sub2API. Los usuarios seleccionados afectan a las peticiones de sus claves API y tienen prioridad sobre las reglas globales.', + userSearchPlaceholder: 'Buscar por correo del usuario', + userSearchEmpty: 'No se encontraron usuarios que coincidan', + userDeleted: '(eliminado)', + userIdFallback: 'Usuario n.º {id}', + removeUser: 'Quitar el usuario', + errorMessage: 'Mensaje de error', + errorMessagePlaceholder: 'Mensaje de error personalizado al bloquear', + errorMessageHint: 'Déjalo vacío para el mensaje por defecto.', + modelWhitelist: 'Lista de modelos permitidos', + modelWhitelistHint: 'Déjalo vacío para aplicarlo a todos los modelos. Admite coincidencia exacta y comodín de prefijo (p. ej. gpt-5.5*).', + modelPatternPlaceholder: 'p. ej. gpt-5.5 o gpt-5.5*', + addModelPattern: 'Añadir un patrón de modelo', + fallbackAction: 'Acción alternativa', + fallbackActionHint: 'Qué hacer con los modelos que no estén en la lista.', + fallbackErrorMessagePlaceholder: 'Mensaje de error personalizado cuando se bloquean modelos fuera de la lista' + }, + wechatConnect: { + title: 'WeChat Connect', + description: 'Configuración del inicio de sesión con terceros para la plataforma abierta de WeChat o para cuentas oficiales y miniprogramas.', + enabledLabel: 'Activar WeChat Connect', + enabledHint: 'Actívalo para configurar los retornos y la autorización de OAuth de WeChat.', + appIdLabel: 'App ID', + appIdPlaceholder: 'App ID de WeChat', + appSecretLabel: 'App Secret', + appSecretConfiguredPlaceholder: 'El secreto está configurado. Déjalo vacío para conservarlo.', + appSecretPlaceholder: 'App Secret de WeChat', + appSecretConfiguredHint: 'El secreto está configurado. Déjalo vacío para conservarlo.', + appSecretHint: 'Introduce un secreto nuevo para sustituir la credencial actual de WeChat.', + modeLabel: 'Modo', + openModeLabel: 'Usar la plataforma abierta fuera de WeChat', + openModeHint: 'Usa la autorización por QR de la plataforma abierta fuera del navegador de WeChat.', + mpModeLabel: 'Usar la cuenta oficial dentro de WeChat', + mpModeHint: 'Usa la autorización de cuenta oficial dentro del navegador de WeChat.', + redirectUrlLabel: 'URL de redirección', + redirectUrlPlaceholder: 'https://tu-sitio.com/api/v1/auth/oauth/wechat/callback', + generateAndCopy: 'Generar y copiar (sitio actual)', + redirectUrlSetAndCopied: 'URL de redirección generada y copiada al portapapeles', + frontendRedirectUrlLabel: 'URL de redirección del frontend', + frontendRedirectUrlPlaceholder: '/auth/wechat/callback', + frontendRedirectUrlHint: 'Suele ser la ruta de retorno del frontend; mantenla alineada con el servidor.' + }, + authSourceDefaults: { + title: 'Valores por defecto por origen de acceso', + description: 'Configura el saldo, la concurrencia, las suscripciones y las reglas de concesión por defecto de cada origen.', + requireEmailLabel: 'Exigir correo en los registros con terceros', + requireEmailHint: 'Si se activa, los registros con Linux DO, OIDC y WeChat tendrán que aportar un correo antes de crear la cuenta.', + enabledHint: 'Estos valores por defecto se aplican cuando un usuario nuevo se registra por este origen. La concesión al vincular por primera vez solo se aplica cuando un usuario existente vincula este origen.', + sources: { + email: { + title: 'Registro por correo', + description: 'Concesiones por defecto para los registros con correo y contraseña.' + }, + linuxdo: { + title: 'Registro con Linux DO', + description: 'Concesiones por defecto para los registros con Linux DO.' + }, + oidc: { + title: 'Registro con OIDC', + description: 'Concesiones por defecto para los registros con OIDC.' + }, + wechat: { + title: 'Registro con WeChat', + description: 'Concesiones por defecto para los registros con WeChat.' + } + }, + grantOnFirstBindLabel: 'Conceder al vincular por primera vez', + grantOnFirstBindHint: 'Concede los beneficios por defecto cuando un usuario existente vincula este origen por primera vez.', + defaultSubscriptionsLabel: 'Suscripciones por defecto', + defaultSubscriptionsHint: 'Se aplica solo a este origen de acceso. Déjalo vacío para no asignar suscripciones específicas de este origen.', + noSourceSubscriptions: 'No hay suscripciones por defecto configuradas para este origen.', + platformQuotasOverride: 'Cuotas por plataforma personalizadas', + platformQuotasOverrideHint: 'Los campos vacíos heredan el valor por defecto del sistema. Pon 0 para bloquear del todo esa ventana en este origen de acceso.', + }, + paymentVisibleMethods: { + methodLabel: 'Método visible de {title}', + methodHint: 'Controla si la caja muestra este método y qué clave de origen expone.', + sourceLabel: 'Origen del pago', + sourceHint: 'Elige un origen explícito antes de activar el método. Los métodos sin configurar no se muestran.', + sourceRequiredError: 'Selecciona un origen de pago antes de activar {title}.' + }, + openaiExperimentalScheduler: { + title: 'Política experimental de planificación de OpenAI', + description: 'Desactivada por defecto. Si se activa, solo cambia la política experimental de selección de cuentas de la pasarela para el tráfico de OpenAI; no indica ninguna capacidad del propio OpenAI.', + lowRatePriorityTitle: 'Preferir las tarifas más bajas', + lowRatePriorityDescription: 'Si se activa, se prefieren las cuentas con tarifas de facturación más bajas. Si las tarifas empatan, se tienen en cuenta la prioridad de la cuenta, la carga actual y otros factores. Este interruptor se ignora cuando el planificador experimental está activo.', + oauthRateTitle: 'Tarifa de referencia para planificar OAuth', + oauthRatePriorityDescription: 'Cuando un grupo tiene cuentas con clave API y cuentas OAuth, esta tarifa se usa para ordenar las cuentas OAuth junto a las tarifas detectadas de las de clave API.', + oauthRateWeightedDescription: 'Cuando un grupo tiene cuentas con clave API y cuentas OAuth, esta tarifa se usa para las cuentas OAuth al calcular la puntuación por tarifa.', + stickyWeightedTitle: 'Ponderación de la persistencia', + stickyWeightedDescription: 'Si se activa, la afinidad por previous_response_id y session_hash se puntúa en el planificador avanzado. Si se desactiva, las cuentas persistentes mantienen el comportamiento clásico de coincidencia dura.', + subscriptionPriorityTitle: 'Prioridad de las suscripciones', + subscriptionPriorityDescription: 'Si se activa, el planificador puntúa primero las cuentas con suscripción de ChatGPT y solo recurre a las que no la tienen si no consigue un hueco con suscripción.', + weightsTitle: 'Pesos personalizados del planificador', + weightsDescription: 'Los valores vacíos usan los de la configuración o el entorno; si no están definidos ahí, se usan los integrados. Los valores rellenados en esta página tienen prioridad.', + defaultPlaceholder: 'configuración/por defecto: {value}', + topKLabel: 'TopK', + priorityWeight: 'Prioridad', + loadWeight: 'Carga', + queueWeight: 'Cola', + errorRateWeight: 'Tasa de error', + ttftWeight: 'TTFT', + resetWeight: 'Ventana de reinicio', + quotaHeadroomWeight: 'Margen de cuota', + upstreamCostWeight: 'Tarifa de facturación', + previousResponseWeight: 'Persistencia por previous_response', + sessionStickyWeight: 'Persistencia por session_hash' + }, + usageRecords: { + title: 'Registros de consumo', + description: 'Ajustes de los registros de consumo y de peticiones fallidas que ven los usuarios finales.', + }, + user_error_view: { + label: 'Permitir que los usuarios vean sus propias peticiones con error', + description: 'Si se activa, los usuarios pueden ver una versión censurada de sus peticiones fallidas en la página de consumo (sin detalles internos ni del proveedor). Necesita la monitorización de operaciones activada para tener datos.', + }, + saveSettings: 'Guardar los ajustes', + saving: 'Guardando...', + settingsSaved: 'Ajustes guardados correctamente', + smtpConnectionSuccess: 'Conexión SMTP correcta', + testEmailSent: 'Correo de prueba enviado correctamente', + failedToLoad: 'No se pudieron cargar los ajustes', + failedToSave: 'No se pudieron guardar los ajustes', + failedToTestSmtp: 'Falló la prueba de conexión SMTP', + failedToSendTestEmail: 'No se pudo enviar el correo de prueba' + }, + + // Error Passthrough Rules + errorPassthrough: { + title: 'Reglas de paso de errores', + description: 'Configura cómo se devuelven al cliente los errores del proveedor', + createRule: 'Crear una regla', + editRule: 'Editar la regla', + deleteRule: 'Eliminar la regla', + noRules: 'No hay reglas configuradas', + createFirstRule: 'Crea tu primera regla de paso de errores', + allPlatforms: 'Todas las plataformas', + passthrough: 'Pasar', + custom: 'Personalizado', + code: 'Código', + body: 'Cuerpo', + skipMonitoring: 'No registrar en la monitorización', + + // Columns + columns: { + priority: 'Prioridad', + name: 'Nombre', + conditions: 'Condiciones', + platforms: 'Plataformas', + behavior: 'Comportamiento', + status: 'Estado', + actions: 'Acciones' + }, + + // Match Mode + matchMode: { + any: 'Código O palabra clave', + all: 'Código Y palabra clave', + anyHint: 'El código de estado coincide con algún código de error, O el mensaje contiene alguna palabra clave', + allHint: 'El código de estado coincide con algún código de error, Y el mensaje contiene alguna palabra clave' + }, + + // Form + form: { + name: 'Nombre de la regla', + namePlaceholder: 'p. ej. Paso del límite de contexto', + priority: 'Prioridad', + priorityHint: 'Los valores más bajos tienen más prioridad', + description: 'Descripción', + descriptionPlaceholder: 'Describe para qué sirve esta regla...', + matchConditions: 'Condiciones de coincidencia', + errorCodes: 'Códigos de error', + errorCodesPlaceholder: '422, 400, 429', + errorCodesHint: 'Separa varios códigos con comas', + keywords: 'Palabras clave', + keywordsPlaceholder: 'Una palabra por línea\nlímite de contexto\nmodelo no admitido', + keywordsHint: 'Una palabra por línea, sin distinguir mayúsculas', + matchMode: 'Modo de coincidencia', + platforms: 'Plataformas', + platformsHint: 'Déjalo vacío para aplicarlo a todas las plataformas', + responseBehavior: 'Comportamiento de la respuesta', + passthroughCode: 'Pasar el código de estado del proveedor', + responseCode: 'Código de estado personalizado', + passthroughBody: 'Pasar el mensaje de error del proveedor', + customMessage: 'Mensaje de error personalizado', + customMessagePlaceholder: 'Mensaje de error que se devolverá al cliente...', + skipMonitoring: 'No registrar en la monitorización', + skipMonitoringHint: 'Si se activa, los errores que coincidan con esta regla no se registrarán en la monitorización de operaciones', + enabled: 'Activar esta regla' + }, + + // Messages + nameRequired: 'Introduce el nombre de la regla', + conditionsRequired: 'Configura al menos un código de error o una palabra clave', + ruleCreated: 'Regla creada correctamente', + ruleUpdated: 'Regla actualizada correctamente', + ruleDeleted: 'Regla eliminada correctamente', + deleteConfirm: '¿Seguro que quieres eliminar la regla «{name}»?', + failedToLoad: 'No se pudieron cargar las reglas', + failedToSave: 'No se pudo guardar la regla', + failedToDelete: 'No se pudo eliminar la regla', + failedToToggle: 'No se pudo cambiar el estado' + }, + + // TLS Fingerprint Profiles + tlsFingerprintProfiles: { + title: 'Perfiles de huella TLS', + description: 'Gestiona los perfiles de huella TLS para simular las características del saludo TLS de clientes concretos', + createProfile: 'Crear un perfil', + editProfile: 'Editar el perfil', + deleteProfile: 'Eliminar el perfil', + noProfiles: 'No hay perfiles configurados', + createFirstProfile: 'Crea tu primer perfil de huella TLS', + + columns: { + name: 'Nombre', + description: 'Descripción', + grease: 'GREASE', + alpn: 'ALPN', + actions: 'Acciones' + }, + + form: { + pasteYaml: 'Pegar la configuración YAML', + pasteYamlPlaceholder: 'Pega aquí la salida YAML del recolector de huellas TLS...', + pasteYamlHint: 'Pega el YAML copiado del recolector de huellas TLS para rellenar todos los campos automáticamente.', + openCollector: 'Abrir el recolector', + parseYaml: 'Interpretar el YAML', + yamlParsed: 'YAML interpretado correctamente, campos rellenados', + yamlParseFailed: 'No se pudo interpretar el YAML: no se encontró el campo name', + name: 'Nombre del perfil', + namePlaceholder: 'p. ej. macOS Node.js v24', + description: 'Descripción', + descriptionPlaceholder: 'Descripción opcional de este perfil', + enableGrease: 'Activar GREASE', + enableGreaseHint: 'Inserta valores GREASE en las extensiones del ClientHello de TLS', + cipherSuites: 'Suites de cifrado', + cipherSuitesHint: 'Valores hexadecimales separados por comas, p. ej. 0x1301, 0x1302, 0xc02c', + curves: 'Curvas elípticas', + curvesHint: 'IDs de curva separados por comas', + pointFormats: 'Formatos de punto', + signatureAlgorithms: 'Algoritmos de firma', + alpnProtocols: 'Protocolos ALPN', + alpnProtocolsHint: 'Separados por comas, p. ej. h2, http/1.1', + supportedVersions: 'Versiones de TLS admitidas', + keyShareGroups: 'Grupos de intercambio de clave', + pskModes: 'Modos PSK', + extensions: 'Extensiones' + }, + + deleteConfirm: 'Eliminar el perfil', + deleteConfirmMessage: '¿Seguro que quieres eliminar el perfil «{name}»? Las cuentas que lo usen volverán al valor por defecto integrado.', + createSuccess: 'Perfil creado correctamente', + updateSuccess: 'Perfil actualizado correctamente', + deleteSuccess: 'Perfil eliminado correctamente', + loadFailed: 'No se pudieron cargar los perfiles', + saveFailed: 'No se pudo guardar el perfil', + deleteFailed: 'No se pudo eliminar el perfil' + } +} diff --git a/frontend/src/i18n/locales/es/batchImage.ts b/frontend/src/i18n/locales/es/batchImage.ts new file mode 100644 index 00000000000..f391c7c4ad0 --- /dev/null +++ b/frontend/src/i18n/locales/es/batchImage.ts @@ -0,0 +1,212 @@ +export default { + batchImage: { + columns: { + taskName: 'Nombre del trabajo', + model: 'Modelo', + apiKey: 'Clave API', + result: 'Resultados', + cost: 'Coste', + downloadStatus: 'Estado de la descarga', + }, + status: { + queued: 'En cola', + running: 'Generando', + processingResults: 'Procesando los resultados', + settling: 'Liquidando', + completed: 'Completado', + failed: 'Fallido', + cancelled: 'Cancelado', + outputDeleted: 'Resultados eliminados', + partialSuccess: 'Completado parcialmente', + allFailed: 'Todos fallaron', + }, + itemStatus: { + pending: 'En cola', + succeeded: 'Correcto', + failed: 'Fallido', + cancelled: 'Cancelado', + recovered: 'Recuperado con un reintento', + }, + filters: { + searchTaskName: 'Buscar por nombre del trabajo', + allApiKeys: 'Todas las claves API', + allStatuses: 'Todos los estados', + allDownloadStates: 'Todos los estados de descarga', + downloaded: 'Descargado', + notDownloaded: 'Sin descargar', + }, + actions: { + usageGuide: 'Guía de uso', + createJob: 'Crear un trabajo por lotes', + downloadSelected: 'Descargar los seleccionados', + deleteRecords: 'Eliminar registros', + retryFailedItems: 'Reintentar los elementos fallidos', + cancelJob: 'Cancelar el trabajo', + downloadZip: 'Descargar el ZIP', + viewDetail: 'Ver los detalles', + download: 'Descargar', + moreActions: 'Más acciones', + copyInstruction: 'Copiar las instrucciones', + submitJob: 'Enviar el trabajo', + }, + list: { + selectedJobs: '{count} trabajo seleccionado | {count} trabajos seleccionados', + expandChildren: 'Expandir {n} subtarea | Expandir {n} subtareas', + collapseChildren: 'Contraer las subtareas', + childCount: '{n} subtarea | {n} subtareas', + childBadge: 'Subtarea', + keyNotRecorded: 'No registrada', + totalCount: 'de {n}', + notDownloaded: 'Sin descargar', + empty: 'Todavía no hay trabajos por lotes', + emptyHint: 'Usa el botón de la esquina superior derecha para crear un trabajo por lotes.', + }, + pagination: { + pageNumber: 'Página {page}', + pageItems: '{count} en esta página', + }, + promptPopover: { + title: 'Prompt completo', + copied: 'Prompt copiado', + }, + detail: { + title: 'Detalles del trabajo', + aggregatedResult: 'Resultados combinados', + result: 'Resultados', + cost: 'Coste', + downloadStatus: 'Estado de la descarga', + items: 'Elementos', + preview: 'Vista previa', + previewZoom: 'Ampliar la vista previa comprimida {id}', + previewReload: 'Recargar la vista previa comprimida', + previewLoad: 'Cargar la vista previa comprimida', + previewUnavailable: 'Vista previa no disponible', + noImage: 'Sin imagen', + loadingItems: 'Cargando los elementos...', + noItems: 'Todavía no hay elementos', + noItemsHint: 'Los trabajos en cola o generando muestran primero los prompts enviados; el estado de las imágenes se actualiza cuando se procesan los resultados.', + mainTask: 'Trabajo principal: {name}', + childTask: 'Subtarea: {name}', + holdCost: 'Retención {amount}', + }, + itemResult: { + recoveredByRetry: 'El fallo anterior se recuperó con una subtarea de reintento', + readyPreview: 'Imagen generada. Haz clic para ver la vista previa.', + readyDownload: 'Imagen generada y lista para descargar.', + noUsableImage: 'No se generó ninguna imagen utilizable.', + cancelled: 'Trabajo cancelado.', + waiting: 'Esperando los resultados.', + emptyImageOutput: 'El proveedor devolvió un resultado, pero este elemento no tiene contenido de imagen. Normalmente significa que una generación de Gemini/Vertex falló o fue bloqueada por las políticas de seguridad.', + providerItemFailed: 'El resultado del proveedor para este elemento no tiene ninguna imagen utilizable.', + }, + imagePreview: { + title: 'Vista previa de la imagen', + notice: 'Esta es una miniatura comprimida guardada en la caché local de tu navegador, así que la calidad es menor. Descarga el ZIP para ver la imagen original.', + }, + create: { + title: 'Crear un trabajo por lotes', + taskName: 'Nombre del trabajo', + taskNamePlaceholder: 'Si lo dejas vacío se usa la hora actual', + loadingKeys: 'Cargando las claves API...', + selectKeyPlaceholder: 'Selecciona una clave API de Gemini', + noKeysHint: 'No hay ninguna clave API de Gemini disponible para generar imágenes por lotes. Crea una y vincúlala primero a un grupo de Gemini que tenga activada la generación de imágenes por lotes.', + model: 'Modelo', + imageSize: 'Tamaño de la imagen', + imageSizeHint: 'Por ahora los trabajos por lotes se envían con un tamaño de imagen fijo de 1K.', + outputFormat: 'Formato de salida', + estimatedOutput: 'Salida estimada', + estimatedOutputValue: '{images} imágenes / {prompts} prompts', + promptAdded: '{count} añadidos', + promptPlaceholder: 'Pega un prompt y luego añádelo a la lista de abajo', + customIdPlaceholder: 'ID personalizado (opcional)', + outputCountPerPrompt: 'Imágenes por prompt', + outputCountOption: '{n} imagen | {n} imágenes', + referenceImage: 'Imágenes de referencia', + removeReferenceImage: 'Quitar la imagen de referencia', + limitsHint: 'Hasta {maxPerItem} imágenes por prompt y {maxPerJob} por trabajo. El modelo actual permite hasta {refLimit} imágenes de referencia por prompt; las imágenes de referencia consumen tokens de entrada una vez por cada imagen generada.', + referenceCount: '{n} imagen de referencia | {n} imágenes de referencia', + noPrompts: 'Todavía no has añadido ningún prompt.', + cancelNotice: 'Cancelar solicita la cancelación al proveedor. Las imágenes ya registradas como correctas se seguirán facturando y se liberará el resto de la retención.', + submittingNotice: 'Creando el trabajo por lotes en el proveedor. Suele tardar unos segundos; no lo envíes otra vez.', + modelNoReferenceImages: 'El modelo actual no admite imágenes de referencia.', + refLimitReached: 'El modelo actual permite hasta {limit} imágenes de referencia por prompt.', + refLimitExceededIgnored: 'El modelo actual permite hasta {limit} imágenes de referencia por prompt. Los archivos sobrantes se ignoraron.', + refFormatUnsupported: 'Las imágenes de referencia deben ser PNG, JPEG o WebP.', + refFileTooLarge: '{name} supera los 10 MB y se ignoró.', + }, + guide: { + title: 'Guía de generación de imágenes por lotes', + uiTitle: 'Cómo usar esta página', + step1: '1. Selecciona una clave API de Gemini con la generación de imágenes por lotes activada. La lista de modelos muestra los disponibles para el grupo de esa clave.', + step2: '2. El nombre del trabajo puede quedar vacío; al enviarlo se usa la hora actual automáticamente. Los prompts se añaden a la lista uno a uno, y cada prompt puede llevar imágenes de referencia y un número de repeticiones.', + step3: '3. Tras enviarlo, el trabajo entra primero en cola y la lista de elementos muestra los prompts enviados. Las vistas previas no se cargan por defecto; pulsa el botón de vista previa de un elemento para cargar una sola imagen.', + step4: '4. Cuando termine podrás descargar el ZIP. Si algunos elementos fallaron, el menú Más te permite reintentar solo esos. La facturación sigue basándose en el número de imágenes generadas correctamente; las imágenes de referencia no se facturan aparte.', + skillTitle: 'Instrucciones de skill para Codex', + skillDesc: 'Le indica a Codex cómo organizar los prompts, enviar trabajos y descargar los resultados en nombre del usuario.', + }, + messages: { + loadKeysFailed: 'No se pudieron cargar las claves API.', + loadModelsFailed: 'No se pudieron cargar los modelos disponibles.', + loadJobsFailed: 'No se pudieron cargar los trabajos por lotes.', + selectApiKey: 'Selecciona una clave API de Gemini disponible.', + noModelsForKey: 'Esta clave no tiene modelos de imagen por lotes disponibles.', + selectModel: 'Selecciona un modelo.', + promptRequired: 'Introduce al menos un prompt.', + submitted: 'Trabajo por lotes enviado.', + submitFailed: 'No se pudo enviar el trabajo por lotes.', + refreshFailed: 'No se pudo actualizar el trabajo.', + cancelConfirm: 'La cancelación se enviará al proveedor. Las imágenes ya registradas como correctas se seguirán facturando y se liberará el resto de la retención. ¿Continuar?', + cancelled: 'Cancelación solicitada.', + cancelFailed: 'No se pudo cancelar el trabajo.', + batchDownloadStarted: 'Han comenzado las descargas de los trabajos seleccionados.', + downloadFailed: 'No se pudo descargar el resultado.', + retrySubmitted: 'Trabajo de reintento enviado para los elementos fallidos.', + retryFailed: 'No se pudieron reintentar los elementos fallidos.', + retryMissingPrompts: 'Este trabajo no tiene guardados los prompts de los elementos fallidos, así que no se puede reintentar automáticamente. Créalo de nuevo con el prompt original.', + retryTaskNameSuffix: 'Reintento de elementos fallidos', + deleteConfirm: 'Esto oculta el trabajo de tu lista pero conserva los registros de facturación. ¿Eliminarlo?', + deleteSelectedConfirm: 'Esto oculta los trabajos seleccionados de tu lista pero conserva los registros de facturación. ¿Eliminarlos?', + deleted: 'Registro del trabajo eliminado.', + deleteFailed: 'No se pudo eliminar el registro del trabajo.', + loadItemsFailed: 'No se pudieron cargar los detalles de los elementos.', + loadPreviewFailed: 'No se pudo cargar la vista previa de la imagen.', + copiedInstruction: 'Instrucciones de imagen por lotes copiadas.', + loadingModels: 'Cargando los modelos disponibles...', + noModels: 'No hay modelos disponibles', + noModelsHint: 'El grupo de esta clave no tiene modelos configurados para la generación de imágenes por lotes.', + noCompatibleAccount: 'No hay ninguna cuenta de proveedor utilizable para imágenes por lotes en el grupo de esta clave. Pide a un administrador que revise la clave API de Gemini o la cuenta de servicio de Vertex del grupo y su compatibilidad de modelos.', + unsupportedProvider: 'El proveedor de imágenes por lotes de este trabajo no está disponible. Pide a un administrador que revise la configuración del proveedor.', + providerSubmitFailed: 'No se pudo enviar el trabajo por lotes al proveedor. Pide a un administrador que revise la cuenta del proveedor, los permisos del modelo o el estado del servicio.', + vertexGcsBucketMissing: 'A la generación de imágenes por lotes con Vertex le falta la configuración del bucket de GCS gestionado. Pide a un administrador que configure BATCH_IMAGE_VERTEX_MANAGED_GCS_BUCKET antes de enviarlo de nuevo.', + queueFailed: 'La cola de tareas no está disponible temporalmente, así que el trabajo por lotes no se encoló. Pide a un administrador que revise el servicio de colas.', + billingHoldFailed: 'Falló la retención del coste, así que el trabajo por lotes no se envió. Pide a un administrador que revise la facturación o el servicio de retención de saldo.', + groupDisabled: 'La generación de imágenes por lotes no está activada para el grupo de esta clave. Elige otra clave que sí la tenga o contacta con un administrador.', + pricingMissing: 'El modelo seleccionado no tiene configurado el precio de imagen por lotes. Pide a un administrador que lo añada primero.', + insufficientBalance: 'Saldo insuficiente para retener el coste estimado de las imágenes por lotes.', + invalidModel: 'Selecciona un modelo de imagen por lotes disponible para la clave actual.', + invalidItems: 'La lista de prompts no es válida. Comprueba que no esté vacía, que respete el límite de elementos y que siga usando el tamaño de imagen 1K.', + duplicateCustomId: 'Los ID personalizados de la lista de prompts deben ser únicos.', + promptTooLong: 'Uno de los prompts es demasiado largo. Acórtalo e inténtalo de nuevo.', + invalidReferenceImage: 'Una imagen de referencia no es válida. Usa PNG, JPEG o WebP de menos de 10 MB.', + tooManyReferenceImages: 'Demasiadas imágenes de referencia. Flash Image permite hasta 3 por elemento, Pro Image hasta 14, y cada trabajo hasta 1000 en total.', + referenceImagesTooLarge: 'Las imágenes de referencia son demasiado grandes. Las imágenes de referencia incrustadas están limitadas a 128 MB por trabajo; usa un file_uri gs:// o divide el trabajo para lotes grandes.', + tooManyOutputImages: 'Demasiadas imágenes de salida previstas. Cada prompt puede pedir hasta 4 imágenes, y cada trabajo puede generar hasta 200.', + idempotencyConflict: 'Este envío entra en conflicto con un ID de petición anterior. Actualiza la página y envíalo de nuevo.', + notReady: 'El trabajo todavía no ha terminado. La descarga estará disponible cuando se complete.', + outputDeleted: 'Los archivos de resultado de este trabajo ya se han limpiado.', + resultMissing: 'El archivo de resultado no está disponible. Puede que se haya limpiado, que los permisos de almacenamiento estén rotos o que la configuración de almacenamiento haya cambiado. Pide a un administrador que revise el archivo de resultado.', + itemFailed: 'Este elemento no tiene ninguna imagen correcta para previsualizar.', + itemImageIndexOutOfRange: 'Este elemento no tiene ninguna imagen que previsualizar.', + downloadLimited: 'Hay demasiadas descargas activas. Inténtalo de nuevo más tarde.', + downloadTooLarge: 'Este ZIP es demasiado grande para una sola descarga. Descarga menos elementos a la vez o pide a un administrador que suba el límite de descarga por lotes.', + deleteNotReady: 'Los registros de trabajos solo se pueden eliminar cuando el trabajo ha terminado.', + disabled: 'La generación de imágenes por lotes está desactivada en este momento.', + authRequired: 'La clave API actual no está disponible o ha caducado. Selecciona la clave de nuevo.', + adminReference: 'Envía el código de error y el ID de la petición a un administrador para que lo revise.', + errorReference: 'Detalle del error', + errorCodeRef: 'código: {code}', + requestIdRef: 'ID de la petición: {id}', + httpStatusRef: 'estado HTTP: {status}', + }, + }, +} diff --git a/frontend/src/i18n/locales/es/channelMonitorV2.ts b/frontend/src/i18n/locales/es/channelMonitorV2.ts new file mode 100644 index 00000000000..154fb61d28e --- /dev/null +++ b/frontend/src/i18n/locales/es/channelMonitorV2.ts @@ -0,0 +1,147 @@ +/** Channel Monitor V2 (user + admin passive monitor UI) */ +export default { + channelMonitorV2: { + title: 'Monitor de canales', + updating: 'Actualizando los datos', + updatedTo: 'Actualizado hasta {time}', + partialCoverage: 'Cobertura histórica parcial', + bootstrap: { + title: 'Construyendo el histórico del monitor', + description: + 'La primera vez que se activa, la agregación pasiva rellena en segundo plano y sin ruido las ventanas de 90 m, 24 h, 7 d y 30 d. Todos los rangos quedan completos cuando termina.', + progress: '{percent}% completado', + working: 'Agregando en segundo plano…', + }, + timeRange: 'Rango de tiempo', + clearFilters: 'Restablecer', + refreshingFilters: 'Cambiaron los filtros; actualizando matriz, tendencia y detalles…', + switchingData: 'Cambiando los datos filtrados…', + summaryAria: 'Resumen del rango seleccionado', + loadFailed: 'No se pudo cargar el monitor de canales', + detailLoadFailed: 'No se pudieron cargar los detalles del monitor de canales', + otherModels: 'Otros modelos', + ignored: 'Ignorado', + currentUser: 'Usuario actual', + ranges: { '90m': '90 m', '24h': '24 h', '7d': '7 d', '30d': '30 d' }, + filters: { + platform: 'Plataforma', allPlatforms: 'Todas', group: 'Grupo', allGroups: 'Todos', model: 'Modelo', allModels: 'Todos', + empty: 'Sin opciones', selectedCount: '{count}', labelValue: '{label}: {value}' + }, + groupBy: { + label: 'Agrupar por', platform: 'Plataforma', platformGroup: 'Plataforma / Grupo', platformModel: 'Plataforma / Modelo', platformGroupModel: 'Plataforma / Grupo / Modelo' + }, + trendView: { label: 'Vista de tendencia', pulse: 'Matriz de pulsos', line: 'Gráfico de líneas' }, + healthMode: { label: 'Indicador de salud', overall: 'General', success: 'Tasa de error', ttft: 'Primer token', cache: 'Tasa de caché' }, + tabs: { aria: 'Dimensión del detalle', models: 'Modelos', errors: 'Motivos de error', users: 'Ranking de usuarios' }, + metrics: { + rpm: 'RPM', + tpm: 'TPM', + tps: 'Tokens/s', + rpmDetail: 'Peticiones por minuto', + tpmDetail: 'Tokens por minuto', + tpsDetail: 'Calculado como TPM ÷ 60', + errorRate: 'Tasa de error', + ttft: 'Primer token', + ttftP50: 'Primer token P50', + durationP50: 'Duración P50', + cacheRate: 'Tasa de caché', + cacheDetail: 'Proporción servida desde caché', + successRate: 'Tasa de éxito', + successRateValue: 'Tasa de éxito {value}', + errorRateValue: 'Tasa de error {value}', + rpmValue: 'RPM {value}', + tpmValue: 'TPM {value}', + tpsValue: 'Tokens/s {value}', + ttftValue: 'Primer token {value}', + durationValue: 'Duración {value}', + cacheRateValue: 'Tasa de caché {value}', + }, + table: { platformModel: 'Plataforma / Modelo', rank: 'Puesto', user: 'Usuario' }, + empty: { title: 'No hay datos que mostrar', description: 'Prueba a cambiar el rango de tiempo o los filtros' }, + bucket: { minutes: 'Intervalos de {count} minutos', hours: 'Intervalos de {count} horas', days: 'Intervalos de {count} días' }, + matrix: { + title: 'Tendencia de disponibilidad', description: 'Cada fila es una dimensión del canal y cada bloque un intervalo agregado; pasa el cursor para ver los detalles', wheelZoom: 'Desplaza el ratón sobre los bloques para acercar (rango más corto, bloques más anchos)', wheelZoomX: 'Desplaza el ratón sobre los bloques para acercar (rango más corto, bloques más anchos)', dimension: 'Dimensión del canal', emptyTitle: 'No hay datos de matriz para la ventana seleccionada', legendAria: 'Leyenda de la puntuación de salud', bad: 'Malo', good: 'Bueno', healthyLegend: 'Saludable (≥80)', warningLegend: 'Vigilar (50–79)', criticalLegend: 'Crítico (<50)', unknownLegend: 'Sin tráfico / muestras insuficientes', noTraffic: 'Sin tráfico en este intervalo', noTrafficAt: '{time} · sin tráfico', scoreLine: 'Puntuación de salud {score}', resetZoom: 'Restablecer el zoom' + }, + chart: { + title: 'Tendencia de disponibilidad', description: 'Tendencia suavizada: tasa de error · primer token P50 · tasa de caché', emptyTitle: 'No hay datos de tendencia para la ventana seleccionada', errorLegend: 'Tasa de error (eje izquierdo %)', cacheLegend: 'Tasa de caché (eje izquierdo %)', ttftLegend: 'Primer token P50 (eje derecho)', errorDataset: 'Tendencia de la tasa de error %', cacheDataset: 'Tendencia de la tasa de caché %', ttftDataset: 'Tendencia del primer token P50 (ms)', percentAxis: 'Tasa %', resetZoom: 'Restablecer el zoom' + }, + errorDetail: { http: 'HTTP {code}', upstream: 'Proveedor {code}', noMessage: 'Sin mensaje de error', empty: 'Solo tasas por categoría (los mensajes de ejemplo son solo para administradores)' }, + errorCategories: { + content_policy: 'Política de contenido', authentication: 'Autenticación', context_limit: 'Límite de contexto', invalid_request: 'Petición no válida', model_unsupported: 'Modelo no compatible', group_access: 'Acceso al grupo', quota_or_balance: 'Cuota o saldo', account_pool_unavailable: 'Grupo de cuentas no disponible', rate_or_capacity: 'Límite de tasa o capacidad', timeout: 'Tiempo agotado', transport_or_stream: 'Transporte o flujo', upstream_forbidden: 'Proveedor prohibido', not_found: 'No encontrado', client_cancelled: 'Cancelado por el cliente', upstream_5xx: 'Error 5xx del proveedor', internal: 'Interno', other: 'Otro' + }, + rank: { + gold: 'Puesto 1 oro', + silver: 'Puesto 2 plata', + bronze: 'Puesto 3 bronce', + place: 'Puesto {n}', + unranked: 'Sin clasificar', + }, + settings: { + title: 'Configuración del monitor de datos V2', + description: + 'Configura las dimensiones de agregación pasiva del consumo (plataforma / modelo / grupo) y la frecuencia de actualización. Los colores de salud y los detalles de la página /monitor del usuario muestran tasas, RPM y TPM, no el volumen absoluto de peticiones.', + save: 'Guardar', + loading: 'Cargando…', + loadFailed: 'No se pudo cargar la configuración V2', + saveSuccess: 'Configuración del monitor V2 guardada', + saveFailed: 'No se pudo guardar la configuración V2', + modeBanner: + 'El modo del sistema es actualmente {mode}. La agregación por minuto de V2 no se ejecutará; esta configuración se puede preparar ahora y se aplicará al cambiar a {modeV2}. Cambia el modo en Ajustes del sistema → Interruptores de funciones.', + modeClosed: 'Monitor de canales desactivado', + modeV1: 'Sondas activas V1', + modeV2: 'Monitorización pasiva V2', + enableTitle: 'Activar la agregación V2', + enableHint: + 'Se aplica cuando el modo del sistema es V2. Desactivarlo solo detiene la agregación de esta configuración; el modo del sistema se sigue cambiando en Interruptores de funciones.', + refreshTitle: 'Intervalo de agregación', + refreshHint: 'Afecta a la granularidad temporal de la matriz y a la frecuencia de actualización', + refreshAria: 'Intervalo de agregación', + platformsTitle: 'Plataformas y modelos', + platformsHint: + 'Vacío = mostrar todos los nombres reales de modelo; si se rellena, solo los modelos indicados tienen fila propia y el resto se agrupa en «Otros»', + modelsPlaceholder: 'Vacío = todos los modelos reales; o indica los modelos populares (el resto → Otros)', + badgeAllModels: 'Todos los modelos', + badgeOther: '+ Otros', + groupsTitle: 'Grupos monitorizados', + groupsSelected: '{count} grupos seleccionados', + groupsAll: 'Todos los grupos', + groupsEmpty: 'No hay grupos disponibles', + errorsTitle: 'Categorías de error e ignorados', + errorsHint: + 'Las categorías marcadas como «ignorar» se excluyen de la tasa de error y de la puntuación de salud, pero siguen apareciendo atenuadas en el desglose de errores. Los errores sin coincidencia se agrupan en «Otros».', + ignoredSummary: 'Ignoradas {ignored} categorías · contadas en la tasa de error {counted} categorías', + healthTitle: 'Umbrales de salud', + healthHint: + 'Controla las bandas de color que ve el usuario y la puntuación general. Los valores por defecto son tolerantes para que una tasa de error pequeña o una caché baja no se muestren de inmediato como problema.', + fields: { + minimumSample: 'Muestras mínimas', + warningError: 'Tasa de error a vigilar %', + criticalError: 'Tasa de error crítica %', + targetTtft: 'TTFT objetivo ms', + warningTtft: 'TTFT a vigilar ms', + criticalTtft: 'TTFT crítico ms', + warningCache: 'Tasa de caché a vigilar %', + criticalCache: 'Tasa de caché crítica %', + }, + namedModelsEmpty: 'Las listas de modelos por plataforma están vacías: se mostrarán todos los nombres reales de modelo (no se agruparán en «Otros»).', + namedModelsCount: 'Mostrando {count} dimensiones de modelo con nombre; los modelos no indicados se agrupan en el «Otros» de cada plataforma.', + userContractTitle: 'Qué se le muestra al usuario', + userContract: { + health: 'Peso de los colores de salud: tasa de error 60 % + primer token P50 20 % + tasa de caché 20 % (umbrales configurables arriba)', + trend: 'La tendencia puede alternar entre matriz de pulsos y gráfico de líneas (error · caché · primer token)', + latency: 'La latencia muestra media · P50 · P90; no se muestran los recuentos absolutos de peticiones ni de errores', + models: 'Con las listas de modelos vacías se muestran los nombres reales y nunca se vuelca todo en «Otros»', + }, + }, + admin: { + descriptionV1: + 'El modo del sistema es sondas activas V1: gestiona los monitores de sondeo y lanza comprobaciones ahora; la agregación V2 no se ejecuta.', + descriptionV2: + 'El modo del sistema es monitorización pasiva V2: configura las dimensiones de agregación; las sondas activas V1 no se ejecutan.', + tabAria: 'Gestión del monitor', + tabV2: 'Configuración del monitor de datos V2', + tabV1Active: 'Sondas activas V1', + tabV1History: 'Histórico V1 (las sondas no están activas en el modo actual)', + }, + }, +} diff --git a/frontend/src/i18n/locales/es/common.ts b/frontend/src/i18n/locales/es/common.ts new file mode 100644 index 00000000000..49fdb339c6a --- /dev/null +++ b/frontend/src/i18n/locales/es/common.ts @@ -0,0 +1,451 @@ +export default { + common: { + loading: 'Cargando...', + submitting: 'Enviando...', + justNow: 'ahora mismo', + peakRateTooltip: 'Tasa máxima: {window}', + peakRateImageNote: '; los tokens de imagen facturados como tokens también se ven afectados, la facturación por imagen no', + save: 'Guardar', + saved: 'Guardado correctamente', + deleted: 'Eliminado correctamente', + cancel: 'Cancelar', + delete: 'Eliminar', + edit: 'Editar', + create: 'Crear', + update: 'Actualizar', + confirm: 'Confirmar', + reset: 'Restablecer', + search: 'Buscar', + filter: 'Filtrar', + export: 'Exportar', + import: 'Importar', + actions: 'Acciones', + status: 'Estado', + name: 'Nombre', + email: 'Correo electrónico', + password: 'Contraseña', + submit: 'Enviar', + back: 'Atrás', + next: 'Siguiente', + yes: 'Sí', + no: 'No', + all: 'Todos', + none: 'Ninguno', + selectAll: 'Seleccionar todo', + noData: 'Sin datos', + expand: 'Expandir', + collapse: 'Contraer', + success: 'Correcto', + error: 'Error', + critical: 'Crítico', + warning: 'Advertencia', + info: 'Información', + active: 'Activo', + inactive: 'Inactivo', + more: 'Más', + close: 'Cerrar', + toggleMenu: 'Mostrar u ocultar el menú', + userMenu: 'Menú de usuario', + pageNotFound: 'Página no encontrada', + enabled: 'Activado', + disabled: 'Desactivado', + total: 'Total', + balance: 'Saldo', + availableBalance: 'Saldo disponible', + frozenBalance: 'Saldo retenido', + totalBalance: 'Saldo total', + available: 'Disponible', + copiedToClipboard: 'Copiado al portapapeles', + copied: 'Copiado', + copyFailed: 'No se pudo copiar', + verifying: 'Verificando...', + processing: 'Procesando...', + contactSupport: 'Contactar con soporte', + add: 'Añadir', + invalidEmail: 'Introduce un correo electrónico válido', + optional: 'opcional', + selectOption: 'Selecciona una opción', + searchPlaceholder: 'Buscar...', + noOptionsFound: 'No se encontraron opciones', + noGroupsAvailable: 'No hay grupos disponibles', + unknownError: 'Se produjo un error desconocido', + saving: 'Guardando...', + selectedCount: '({count} seleccionados)', + refresh: 'Actualizar', + autoRefresh: { + title: 'Actualización automática', + enable: 'Activar la actualización automática', + countdown: 'Actualización automática: {seconds} s', + seconds: '{n} segundos', + }, + view: 'Ver', + settings: 'Ajustes', + chooseFile: 'Elegir archivo', + upload: 'Subir', + remove: 'Quitar', + noFileSelected: 'Ningún archivo seleccionado', + selectedFile: 'Seleccionado: {name}', + fileReadFailed: 'No se pudo leer el archivo', + selectImageFile: 'Selecciona un archivo de imagen', + fileTooLargeKb: 'Archivo demasiado grande ({size} KB), máximo {max} KB', + copy: 'Copiar', + notAvailable: 'N/D', + now: 'Ahora', + today: 'Hoy', + tomorrow: 'Mañana', + unknown: 'Desconocido', + minutes: 'min', + time: { + never: 'Nunca', + justNow: 'Ahora mismo', + minutesAgo: 'hace {n} min', + hoursAgo: 'hace {n} h', + daysAgo: 'hace {n} d', + countdown: { + daysHours: '{d} d {h} h', + hoursMinutes: '{h} h {m} min', + minutes: '{m} min', + withSuffix: '{time} para levantarse' + } + } + }, + + adminCompliance: { + title: 'Declaración de conformidad para el despliegue y la operación', + blockingNotice: 'Es necesario aceptar la declaración de conformidad de despliegue y operación antes de seguir usando el panel.', + riskNotice: 'Esta declaración deja constancia clara, visible y reproducible de las obligaciones de cumplimiento y de los riesgos de operación de las instancias autoalojadas.', + version: 'Versión del documento', + openDocument: 'Abrir el documento en GitHub', + documentSource: 'El texto del acuerdo proviene de archivos Markdown del repositorio de este proyecto. Cuando el contenido del acuerdo cambia, debe incrementarse la versión del documento; las aceptaciones de versiones anteriores dejan de ser válidas y los usuarios del panel deben aceptarlo de nuevo.', + inputLabel: 'Escribe exactamente la siguiente frase de confirmación', + inputPlaceholder: 'Escribe la frase de confirmación para continuar', + inputMismatch: 'La frase de confirmación no coincide. Escribe exactamente el texto que se muestra.', + legalNote: 'Esta declaración define la ausencia de vínculo y el límite de responsabilidad entre las instancias autoalojadas y el proyecto de código abierto, los titulares de los derechos de autor, los colaboradores y los mantenedores. Quien despliega, opera o controla la instancia correspondiente es el único responsable de las obligaciones que le apliquen.', + logout: 'Cerrar sesión', + accept: 'Aceptar y continuar', + accepted: 'Declaración de conformidad registrada', + acceptFailed: 'No se pudo enviar la declaración' + }, + + legal: { + loadFailed: 'No se pudo cargar el documento', + retryLater: 'Actualiza la página e inténtalo de nuevo más tarde.', + notFound: 'Documento no encontrado', + notFoundDescription: 'Este documento legal no existe o un administrador lo ha eliminado.', + updatedAt: 'Actualizado: {date}', + empty: 'Sin contenido', + loginAgreement: 'Acuerdo de inicio de sesión', + adminCompliance: 'Compromiso de conformidad para el despliegue y la operación', + loginAgreementPrompt: { + checkboxPrefix: 'He leído y acepto ', + documentSeparator: ', ', + noticeTitle: 'Acepta los términos más recientes antes de continuar.', + noticeDescription: 'El inicio de sesión con correo y contraseña y el acceso rápido seguirán desactivados hasta que los aceptes.', + viewTerms: 'Ver los términos', + dialogTitle: 'Aviso de actualización de términos', + dialogDescription: 'Nuestros términos del servicio se actualizaron el {date}. Léelos y acéptalos antes de continuar.', + recently: 'recientemente', + relatedDocuments: 'Documentos relacionados', + reject: 'Rechazar', + accept: 'Aceptar y continuar', + loginRejectedWarning: 'El inicio de sesión con correo y contraseña y el acceso rápido están desactivados hasta que aceptes los términos más recientes.', + loginRequiredWarning: 'Lee y acepta los términos más recientes antes de iniciar sesión.', + registerRejectedWarning: 'El registro y el acceso rápido están desactivados hasta que aceptes los términos más recientes.', + registerRequiredWarning: 'Lee y acepta los términos más recientes antes de registrarte.' + } + }, + + // Navigation + nav: { + dashboard: 'Panel', + announcements: 'Anuncios', + apiKeys: 'Claves API', + batchImage: 'Imágenes por lotes', + usage: 'Consumo', + redeem: 'Canjear', + affiliate: 'Comisiones de afiliados', + affiliateManagement: 'Comisiones de afiliados', + affiliateInviteRecords: 'Registro de invitaciones', + affiliateRebateRecords: 'Registro de comisiones', + affiliateTransferRecords: 'Registro de transferencias', + profile: 'Perfil', + users: 'Usuarios', + groups: 'Grupos', + channels: 'Canales', + availableChannels: 'Canales disponibles', + modelPlaza: 'Catálogo de modelos', + subscriptions: 'Suscripciones', + accounts: 'Cuentas', + proxies: 'Proxies', + redeemCodes: 'Códigos de canje', + ops: 'Operaciones', + promoCodes: 'Códigos promocionales', + settings: 'Ajustes', + myAccount: 'Mi cuenta', + lightMode: 'Modo claro', + darkMode: 'Modo oscuro', + collapse: 'Contraer', + expand: 'Expandir', + logout: 'Cerrar sesión', + github: 'GitHub', + mySubscriptions: 'Mis suscripciones', + buySubscription: 'Recargar / Suscripción', + docs: 'Documentación', + myOrders: 'Mis pedidos', + orderManagement: 'Pedidos', + paymentDashboard: 'Panel de pagos', + paymentConfig: 'Configuración de pagos', + paymentPlans: 'Planes', + channelManagement: 'Canales', + channelPricing: 'Precios por canal', + channelMonitor: 'Monitor de canales', + channelStatus: 'Estado de los canales', + riskControl: 'Control de riesgos', + securityAudit: 'Auditoría de seguridad', + contentModeration: 'Moderación de contenido', + promptAudit: 'Auditoría de prompts', + auditLogs: 'Registros de auditoría', + }, + + // Auth + auth: { + welcomeBack: 'Bienvenido de nuevo', + signInToAccount: 'Inicia sesión en tu cuenta para continuar', + signIn: 'Iniciar sesión', + signingIn: 'Iniciando sesión...', + passkeySignIn: 'Iniciar sesión con una passkey', + passkeySigningIn: 'Esperando la passkey...', + passkeyCancelled: 'Se canceló el inicio de sesión con passkey.', + passkeyFailed: 'Falló el inicio de sesión con passkey. Inténtalo de nuevo.', + createAccount: 'Crear cuenta', + signUpToStart: 'Regístrate para empezar a usar {siteName}', + signUp: 'Registrarse', + processing: 'Procesando...', + continue: 'Continuar', + rememberMe: 'Recordarme', + dontHaveAccount: '¿No tienes una cuenta?', + alreadyHaveAccount: '¿Ya tienes una cuenta?', + registrationDisabled: 'El registro está desactivado en este momento. Contacta con el administrador.', + emailLabel: 'Correo electrónico', + emailPlaceholder: 'Introduce tu correo electrónico', + passwordLabel: 'Contraseña', + passwordPlaceholder: 'Introduce tu contraseña', + createPasswordPlaceholder: 'Crea una contraseña segura', + passwordHint: 'Al menos 6 caracteres', + emailRequired: 'El correo electrónico es obligatorio', + invalidEmail: 'Introduce un correo electrónico válido', + passwordRequired: 'La contraseña es obligatoria', + passwordMinLength: 'La contraseña debe tener al menos 6 caracteres', + loginFailed: 'Error al iniciar sesión. Revisa tus datos e inténtalo de nuevo.', + errors: { + USER_NOT_ACTIVE: 'La cuenta ha sido desactivada.', + }, + registrationFailed: 'Error en el registro. Inténtalo de nuevo.', + emailDomainRegistrationLimit: + 'Este dominio de correo no puede registrar otra cuenta. Usa un correo de un proveedor común o contacta con soporte para añadir el dominio de tu empresa a la lista de permitidos.', + emailSuffixNotAllowed: 'Este dominio de correo no está permitido para el registro.', + emailSuffixNotAllowedWithAllowed: + 'Este dominio de correo no está permitido. Dominios permitidos: {suffixes}', + emailSuffixAllowedMore: 'y {count} más', + loginSuccess: '¡Sesión iniciada! Bienvenido de nuevo.', + accountCreatedSuccess: '¡Cuenta creada correctamente! Te damos la bienvenida a {siteName}.', + reloginRequired: 'La sesión ha caducado. Inicia sesión de nuevo.', + turnstileExpired: 'La verificación ha caducado, inténtalo de nuevo', + turnstileFailed: 'La verificación falló, inténtalo de nuevo', + captchaVerified: 'Verificación completada', + captchaLoading: 'Cargando la verificación…', + captchaClickToVerify: 'Haz clic para completar la verificación', + captchaVerifying: 'Verificando…', + completeVerification: 'Completa la verificación', + verifyYourEmail: 'Verifica tu correo electrónico', + sessionExpired: 'La sesión ha caducado', + sessionExpiredDesc: 'Vuelve a la página de registro y empieza de nuevo.', + verificationCode: 'Código de verificación', + verificationCodeHint: 'Introduce el código de 6 dígitos que enviamos a tu correo', + sendingCode: 'Enviando...', + sendCode: 'Enviar código', + clickToResend: 'Haz clic para reenviar el código', + resendCode: 'Reenviar el código de verificación', + sendCodeDesc: 'Enviaremos un código de verificación a', + codeSentSuccess: '¡Código de verificación enviado! Revisa tu bandeja de entrada.', + verifying: 'Verificando...', + verifyAndCreate: 'Verificar y crear la cuenta', + resendCountdown: 'Reenviar el código en {countdown} s', + backToRegistration: 'Volver al registro', + sendCodeFailed: 'No se pudo enviar el código de verificación. Inténtalo de nuevo.', + verifyFailed: 'La verificación falló. Inténtalo de nuevo.', + codeRequired: 'El código de verificación es obligatorio', + invalidCode: 'Introduce un código válido de 6 dígitos', + promoCodeLabel: 'Código promocional', + promoCodePlaceholder: 'Introduce el código promocional (opcional)', + promoCodeValid: '¡Válido! Recibirás ${amount} de saldo de regalo', + promoCodeInvalid: 'Código promocional no válido', + promoCodeNotFound: 'No se encontró el código promocional', + promoCodeExpired: 'Este código promocional ha caducado', + promoCodeDisabled: 'Este código promocional está desactivado', + promoCodeMaxUsed: 'Este código promocional alcanzó su límite de usos', + promoCodeAlreadyUsed: 'Ya has usado este código promocional', + promoCodeValidating: 'Se está validando el código promocional, espera un momento', + promoCodeInvalidCannotRegister: 'Código promocional no válido. Revísalo e inténtalo de nuevo, o deja el campo vacío', + invitationCodeLabel: 'Código de invitación', + invitationCodePlaceholder: 'Introduce el código de invitación', + invitationCodeRequired: 'El código de invitación es obligatorio', + invitationCodeValid: 'El código de invitación es válido', + invitationCodeInvalid: 'Código de invitación no válido o ya usado', + invitationCodeValidating: 'Validando el código de invitación...', + invitationCodeInvalidCannotRegister: 'Código de invitación no válido. Revísalo e inténtalo de nuevo', + oauthOrContinue: 'o continúa con otros métodos', + linuxdo: { + signIn: 'Continuar con Linux.do', + orContinue: 'o continúa con tu correo electrónico', + callbackTitle: 'Iniciando tu sesión', + callbackProcessing: 'Completando el inicio de sesión, espera un momento...', + callbackHint: 'Si no se te redirige automáticamente, vuelve a la página de inicio de sesión e inténtalo de nuevo.', + callbackMissingToken: 'Falta el token de inicio de sesión, inténtalo de nuevo.', + backToLogin: 'Volver al inicio de sesión', + invitationRequired: 'Esta cuenta de Linux.do todavía no está registrada. El sitio requiere un código de invitación: introduce uno para completar el registro.', + invalidPendingToken: 'El token de registro ha caducado. Inicia sesión de nuevo con Linux.do.', + completeRegistration: 'Completar el registro', + completing: 'Completando el registro…', + completeRegistrationFailed: 'Error en el registro. Revisa tu código de invitación e inténtalo de nuevo.' + }, + dingtalk: { + signIn: 'Continuar con DingTalk', + callbackTitle: 'Iniciando tu sesión con DingTalk', + callbackProcessing: 'Completando el inicio de sesión con DingTalk, espera un momento...', + callbackHint: 'Si no se te redirige automáticamente, vuelve a la página de inicio de sesión e inténtalo de nuevo.', + callbackMissingToken: 'Falta el token de inicio de sesión, inténtalo de nuevo.', + backToLogin: 'Volver al inicio de sesión', + invitationRequired: 'Esta cuenta de DingTalk todavía no está registrada. El sitio requiere un código de invitación: introduce uno para completar el registro.', + invalidPendingToken: 'El token de registro ha caducado. Inicia sesión de nuevo con DingTalk.', + completeRegistration: 'Completar el registro', + completing: 'Completando el registro…', + completeRegistrationFailed: 'Error en el registro. Revisa tu código de invitación e inténtalo de nuevo.', + createAccountTitle: 'Crear una cuenta de DingTalk', + registrationDisabledRedirectToBind: 'El registro de cuentas nuevas está desactivado en este momento. Vincula tu cuenta existente con su correo y contraseña.', + error: { + title: 'Error al iniciar sesión con DingTalk', + csrf: 'La sesión de inicio caducó, vuelve a escanear el código', + corp_rejected: 'Tu cuenta de DingTalk no pertenece a esta organización. Contacta con el administrador', + dingtalk_not_enabled: 'El inicio de sesión con DingTalk no está activado', + upstream_error: 'El servicio de DingTalk no está disponible temporalmente. Inténtalo más tarde', + missing_browser_session: 'Se perdió la sesión del navegador. Inicia sesión de nuevo', + missing_params: 'Los parámetros de la solicitud están incompletos', + invalid_state: 'Estado de inicio de sesión no válido', + provider_error: 'Falló la autorización de DingTalk', + session_error: 'No se pudo crear la sesión. Inténtalo de nuevo', + retry: 'Reintentar el inicio de sesión' + } + }, + emailOAuth: { + signIn: 'Continuar con {providerName}' + }, + oidc: { + signIn: 'Continuar con {providerName}', + callbackTitle: 'Iniciando tu sesión con {providerName}', + callbackProcessing: 'Completando el inicio de sesión con {providerName}, espera un momento...', + callbackHint: 'Si no se te redirige automáticamente, vuelve a la página de inicio de sesión e inténtalo de nuevo.', + callbackMissingToken: 'Falta el token de inicio de sesión, inténtalo de nuevo.', + backToLogin: 'Volver al inicio de sesión', + invitationRequired: + 'Esta cuenta de {providerName} todavía no está registrada. El sitio requiere un código de invitación: introduce uno para completar el registro.', + invalidPendingToken: 'El token de registro ha caducado. Inicia sesión de nuevo.', + completeRegistration: 'Completar el registro', + completing: 'Completando el registro…', + completeRegistrationFailed: 'Error en el registro. Revisa tu código de invitación e inténtalo de nuevo.' + }, + oauthFlow: { + profileDetailsTitle: 'Usar los datos del perfil de {providerName}', + profileDetailsDescription: 'Elige si quieres aplicar a esta cuenta el nombre o la foto de perfil de {providerName}.', + useDisplayName: 'Usar el nombre visible', + useAvatar: 'Usar la foto de perfil', + avatarAlt: 'Foto de perfil de {providerName}', + reviewProfileBeforeContinue: 'Revisa los datos del perfil de {providerName} antes de continuar.', + chooseHowToContinue: 'Elige cómo continuar', + chooseAccountActionHint: 'Elige si quieres vincular una cuenta existente o crear una nueva.', + suggestedEmail: 'Correo sugerido: {email}', + bindExistingAccount: 'Vincular una cuenta existente', + createNewAccount: 'Crear una cuenta nueva', + createAccountHint: 'Introduce un correo electrónico para crear tu cuenta y continuar.', + bindLoginHint: 'Inicia sesión en una cuenta existente para vincular este acceso con {providerName}.', + signInThenBindDescription: 'Inicia sesión en una cuenta existente y luego vincula este acceso con {providerName}.', + bindSignInToExistingAccount: 'Vincular este acceso con {providerName} a una cuenta existente.', + bindCurrentAccountTitle: 'Vincular la cuenta actual', + bindCurrentAccountDescription: 'Vincula este acceso con {providerName} a la cuenta que tiene la sesión abierta en este navegador.', + bindCurrentAccount: 'Vincular la cuenta actual', + logInAndBind: 'Iniciar sesión y vincular', + useDifferentEmail: 'Usar otro correo electrónico', + backToOptions: 'Volver a las opciones', + yourAccount: 'tu cuenta', + totpHint: 'Introduce el código de 6 dígitos de {account} para terminar de vincular este acceso con {providerName}.', + verifyAndContinue: 'Verificar y continuar', + wechatAvailabilityUnknown: 'No se pudo confirmar si el inicio de sesión con WeChat está disponible. Actualiza la página e inténtalo de nuevo.', + wechatSystemBrowserOnly: 'Este inicio de sesión con WeChat solo funciona en el navegador del sistema.', + wechatBrowserOnly: 'Este inicio de sesión con WeChat solo funciona dentro del navegador de WeChat.', + wechatNotConfigured: 'El inicio de sesión con WeChat todavía no está configurado.' + }, + linuxdoCallbackPageTitle: 'Retorno del inicio de sesión con LinuxDo', + dingtalkCallbackPageTitle: 'Retorno del inicio de sesión con DingTalk', + dingtalkProviderName: 'DingTalk', + oidcCallbackPageTitle: 'Retorno del inicio de sesión con OIDC', + oauthCallbackPageTitle: 'Retorno de OAuth', + wechatProviderName: 'WeChat', + wechatCallbackPageTitle: 'Retorno del inicio de sesión con WeChat', + wechatPaymentCallbackPageTitle: 'Retorno del pago con WeChat', + wechatPayment: { + callbackTitle: 'Reanudando el pago con WeChat', + callbackProcessing: 'Reanudando el pago con WeChat...', + backToPayment: 'Volver al pago', + callbackMissingResumeToken: 'Al retorno del pago con WeChat le falta el token para reanudar.' + }, + oauth: { + callbackTitle: 'Retorno de OAuth', + callbackHint: 'Copia el código y el estado de vuelta al flujo de autorización del administrador cuando sea necesario.', + invalidCallbackTitle: 'Retorno de inicio de sesión no válido', + invalidCallbackHint: 'Esta página no contiene un resultado de autorización válido. Vuelve a la página de inicio de sesión y comienza de nuevo el acceso rápido.', + code: 'Código', + state: 'Estado', + fullUrl: 'URL completa' + }, + // Forgot password + forgotPassword: '¿Olvidaste tu contraseña?', + forgotPasswordTitle: 'Restablece tu contraseña', + forgotPasswordHint: 'Introduce tu correo electrónico y te enviaremos un enlace para restablecer tu contraseña.', + sendResetLink: 'Enviar el enlace', + sendingResetLink: 'Enviando...', + sendResetLinkFailed: 'No se pudo enviar el enlace. Inténtalo de nuevo.', + resetEmailSent: 'Enlace enviado', + resetEmailSentHint: 'Si existe una cuenta con este correo, recibirás en breve un enlace para restablecer la contraseña. Revisa tu bandeja de entrada y la carpeta de spam.', + backToLogin: 'Volver al inicio de sesión', + rememberedPassword: '¿Recordaste tu contraseña?', + // Reset password + resetPasswordTitle: 'Establecer una contraseña nueva', + resetPasswordHint: 'Introduce tu nueva contraseña abajo.', + newPassword: 'Contraseña nueva', + newPasswordPlaceholder: 'Introduce tu contraseña nueva', + confirmPassword: 'Confirmar la contraseña', + confirmPasswordPlaceholder: 'Confirma tu contraseña nueva', + confirmPasswordRequired: 'Confirma tu contraseña', + passwordsDoNotMatch: 'Las contraseñas no coinciden', + resetPassword: 'Restablecer la contraseña', + resettingPassword: 'Restableciendo...', + resetPasswordFailed: 'No se pudo restablecer la contraseña. Inténtalo de nuevo.', + passwordResetSuccess: 'Contraseña restablecida', + passwordResetSuccessHint: 'Tu contraseña se ha restablecido. Ya puedes iniciar sesión con la nueva.', + invalidResetLink: 'Enlace no válido', + invalidResetLinkHint: 'Este enlace para restablecer la contraseña no es válido o ha caducado. Solicita uno nuevo.', + requestNewResetLink: 'Solicitar un enlace nuevo', + invalidOrExpiredToken: 'El enlace para restablecer la contraseña no es válido o ha caducado. Solicita uno nuevo.' + }, + + // Step-up (sudo) 2FA prompt + stepUp: { + title: 'Se requiere verificación en dos pasos', + hint: 'Introduce el código de 6 dígitos de tu app de autenticación para continuar con esta operación sensible.', + verifyFailed: 'La verificación falló, inténtalo de nuevo', + notEnabled: 'Esta operación requiere verificación en dos pasos. Activa primero TOTP en tu perfil.', + adminApiKeyForbidden: 'Las claves API de administrador no pueden realizar esta operación. Usa una sesión de administrador con verificación en dos pasos.' + }, + + // Dashboard +} diff --git a/frontend/src/i18n/locales/es/dashboard.ts b/frontend/src/i18n/locales/es/dashboard.ts new file mode 100644 index 00000000000..62860a2fc8b --- /dev/null +++ b/frontend/src/i18n/locales/es/dashboard.ts @@ -0,0 +1,928 @@ +export default { + dashboard: { + title: 'Panel', + welcomeMessage: '¡Bienvenido de nuevo! Aquí tienes un resumen de tu cuenta.', + balance: 'Saldo', + apiKeys: 'Claves API', + todayRequests: 'Peticiones de hoy', + todayCost: 'Coste de hoy', + todayTokens: 'Tokens de hoy', + totalTokens: 'Tokens totales', + cacheToday: 'Caché (hoy)', + performance: 'Rendimiento', + avgResponse: 'Respuesta media', + averageTime: 'Tiempo medio', + timeRange: 'Rango de tiempo', + granularity: 'Granularidad', + day: 'Día', + hour: 'Hora', + modelDistribution: 'Reparto por modelo', + groupDistribution: 'Reparto del consumo por grupo', + platformBreakdown: 'Desglose por plataforma', + platformBreakdownEmpty: 'Todavía no hay consumo por plataforma', + platformCount: '{count} plataformas', + platformOther: 'Otras', + platformQuota: { + title: 'Consumo de la cuota', + daily: 'Diaria', + weekly: 'Semanal', + monthly: 'Mensual (30 días móviles)', + resetsAt: 'Se restablece {time}', + noLimit: 'ilimitada', + disabled: 'Desactivada', + }, + tokenUsageTrend: 'Tendencia de consumo de tokens', + noDataAvailable: 'No hay datos disponibles', + model: 'Modelo', + group: 'Grupo', + noGroup: 'Sin grupo', + requests: 'Peticiones', + tokens: 'Tokens', + actual: 'Real', + standard: 'Estándar', + input: 'Entrada', + output: 'Salida', + cache: 'Caché', + recentUsage: 'Consumo reciente', + last7Days: 'Últimos 7 días', + noUsageRecords: 'No hay registros de consumo', + startUsingApi: 'Empieza a usar la API para ver aquí tu historial de consumo.', + viewAllUsage: 'Ver todo el consumo', + quickActions: 'Acciones rápidas', + createApiKey: 'Crear una clave API', + generateNewKey: 'Genera una clave API nueva', + batchImageAgent: 'Asistente de imágenes por lotes', + batchImageAgentDesc: 'Copia las instrucciones para un agente', + viewUsage: 'Ver el consumo', + checkDetailedLogs: 'Consulta los registros detallados de consumo', + redeemCode: 'Canjear un código', + addBalanceWithCode: 'Añade saldo con un código' + }, + + // Groups (shared) + groups: { + subscription: 'Susc.' + }, + + // API Keys + keys: { + title: 'Claves API', + description: 'Gestiona tus claves API y tokens de acceso', + searchPlaceholder: 'Buscar por nombre o clave...', + endpoints: { + title: 'Endpoints de la API', + default: 'Por defecto', + copied: 'Copiado', + copiedHint: 'Copiado al portapapeles', + clickToCopy: 'Pulsa para copiar este endpoint', + speedTest: 'Prueba de velocidad', + }, + allGroups: 'Todos los grupos', + allStatus: 'Todos los estados', + columnSettings: 'Configuración de columnas', + columnAlwaysVisible: 'Esta columna siempre está visible', + createKey: 'Crear una clave API', + editKey: 'Editar la clave API', + deleteKey: 'Eliminar la clave API', + deleteConfirmMessage: '¿Seguro que quieres eliminar «{name}»? Esta acción no se puede deshacer.', + id: 'ID', + apiKey: 'Clave API', + group: 'Grupo', + currentConcurrency: 'Concurrencia actual', + noGroup: 'Sin grupo', + searchGroup: 'Buscar grupos...', + noGroupFound: 'No se encontraron grupos', + created: 'Creada', + copyToClipboard: 'Copiar al portapapeles', + copied: '¡Copiado!', + importToCcSwitch: 'Importar a CCS', + enable: 'Activar', + disable: 'Desactivar', + nameLabel: 'Nombre', + namePlaceholder: 'Mi clave API', + groupLabel: 'Grupo', + selectGroup: 'Selecciona un grupo', + statusLabel: 'Estado', + selectStatus: 'Selecciona el estado', + saving: 'Guardando...', + noKeysYet: 'Todavía no hay claves API', + createFirstKey: 'Crea tu primera clave API para empezar a usar la API.', + keyCreatedSuccess: 'Clave API creada correctamente', + keyUpdatedSuccess: 'Clave API actualizada correctamente', + keyDeletedSuccess: 'Clave API eliminada correctamente', + keyEnabledSuccess: 'Clave API activada correctamente', + keyDisabledSuccess: 'Clave API desactivada correctamente', + failedToLoad: 'No se pudieron cargar las claves API', + failedToSave: 'No se pudo guardar la clave API', + failedToDelete: 'No se pudo eliminar la clave API', + failedToUpdateStatus: 'No se pudo actualizar el estado de la clave API', + clickToChangeGroup: 'Pulsa para cambiar de grupo', + groupChangedSuccess: 'Grupo cambiado correctamente', + failedToChangeGroup: 'No se pudo cambiar el grupo', + groupRequired: 'Selecciona un grupo', + usage: 'Consumo', + today: 'Hoy', + total: 'Últimos 30 d', + quota: 'Cuota', + lastUsedAt: 'Último uso', + lastUsedIP: 'IP del último uso', + useKey: 'Usar la clave', + useKeyModal: { + title: 'Usar la clave API', + description: + 'Añade las siguientes variables de entorno al perfil de tu terminal o ejecútalas directamente en la terminal para configurar el acceso a la API.', + copy: 'Copiar', + copied: 'Copiado', + note: 'Estas variables de entorno estarán activas en la sesión actual de la terminal. Para una configuración permanente, añádelas a ~/.bashrc, ~/.zshrc o al archivo de configuración correspondiente.', + claudeSettingsHint: 'Configuración persistente a nivel de usuario. No subas este archivo con tu clave API al repositorio de un proyecto.', + noGroupTitle: 'Asigna primero un grupo', + noGroupDescription: 'Esta clave API no está asignada a ningún grupo. Pulsa la columna de grupo en la lista de claves para asignarle uno antes de ver la configuración.', + openai: { + description: 'Añade los siguientes archivos de configuración al directorio de configuración de Codex CLI.', + authModeTitle: 'Modo de autenticación de Codex', + authModeDescription: 'El modo de compatibilidad mantiene la configuración actual para clientes Codex antiguos. El modo Clave API autoriza el ejecutor de imágenes del cliente.', + authModeLegacy: 'Modo de compatibilidad', + authModeApiKey: 'Modo Clave API', + authModeApiKeyRestartNotice: 'Después de guardar esta configuración, cierra por completo y vuelve a abrir Codex Desktop o CLI, y crea una tarea nueva para que el cliente reconstruya su registro de herramientas.', + configTomlHint: 'Asegúrate de que el siguiente contenido esté al principio del archivo config.toml', + note: 'Asegúrate de que el directorio de configuración exista. En macOS y Linux puedes crearlo con mkdir -p ~/.codex.', + noteWindows: 'Pulsa Win+R y escribe %userprofile%\\.codex para abrir el directorio de configuración. Créalo a mano si no existe.', + }, + cliTabs: { + claudeCode: 'Claude Code', + geminiCli: 'Gemini CLI', + codexCli: 'Codex CLI', + codexCliWs: 'Codex CLI (WebSocket)', + grokCli: 'Grok CLI', + opencode: 'OpenCode', + }, + antigravity: { + description: 'Configura el acceso a la API para el grupo Antigravity. Elige el método de configuración según tu cliente.', + claudeCode: 'Claude Code', + geminiCli: 'Gemini CLI', + claudeNote: 'Estas variables de entorno estarán activas en la sesión actual de la terminal. Para una configuración permanente, añádelas a ~/.bashrc, ~/.zshrc o al archivo de configuración correspondiente.', + geminiNote: 'Estas variables de entorno estarán activas en la sesión actual de la terminal. Para una configuración permanente, añádelas a ~/.bashrc, ~/.zshrc o al archivo de configuración correspondiente.', + }, + gemini: { + description: 'Añade las siguientes variables de entorno al perfil de tu terminal o ejecútalas directamente en la terminal para configurar el acceso a Gemini CLI.', + modelComment: 'Si tienes acceso a Gemini 3, puedes usar: gemini-3-pro-preview', + note: 'Estas variables de entorno estarán activas en la sesión actual de la terminal. Para una configuración permanente, añádelas a ~/.bashrc, ~/.zshrc o al archivo de configuración correspondiente.', + }, + grok: { + description: + 'Configura Grok CLI, Claude Code, Codex u OpenCode para que envíen las peticiones a través de tu grupo Grok de Sub2API. Los modelos de texto usan Responses; imagen y vídeo usan los ID de modelo Imagine en los endpoints multimedia.', + claudeDescription: 'Configura Claude Code para que envíe el tráfico de la API Messages a través de tu grupo Grok de Sub2API.', + codexDescription: 'Configura Codex para que envíe el tráfico de la API Responses a través de tu grupo Grok de Sub2API.', + configTomlHint: + 'Ruta oficial: ~/.grok/config.toml (o $GROK_HOME). Rellena [endpoints] (models_base_url / models_list_url / xai_api_base_url / cli_chat_proxy_base_url), [auth] preferred_method=api_key, [models], [session] y las sustituciones de imagen/vídeo en [features]. Es preferible env_key a api_key; todos los modelos de texto necesitan api_backend=responses. Haz una copia de seguridad antes de combinar y luego ejecuta grok inspect.', + codexConfigTomlHint: + 'Codex oficial: wire_api = "responses" únicamente; es preferible env_key a experimental_bearer_token; supports_websockets = false para pasarelas que no sean de OpenAI (Sub2API puede aceptar WS del cliente y traducirlo a HTTP/SSE). Haz una copia de ~/.codex/config.toml antes de combinar.', + note: + 'Exporta GROK_MODELS_BASE_URL y XAI_API_KEY, guarda el config.toml completo (endpoints/auth/models/session/features) como ~/.grok/config.toml, ejecuta grok inspect y luego /model grok-4.5 (o grok-build-0.1 para programar).', + noteWindows: + 'Define GROK_MODELS_BASE_URL y XAI_API_KEY, guarda el config.toml completo como %USERPROFILE%\\.grok\\config.toml, ejecuta grok inspect y luego /model grok-4.5 (o grok-build-0.1 para programar).', + claudeNote: + 'Elige un método: variables de entorno de la terminal para esta sesión, o ~/.claude/settings.json para que sea permanente. No subas archivos que contengan tu clave API.', + codexNote: + 'Exporta SUB2API_API_KEY y guarda config.toml en ~/.codex (mkdir -p ~/.codex). Es preferible la autenticación con env_key; no subas secretos al repositorio.', + codexNoteWindows: + 'Define $env:SUB2API_API_KEY y guarda config.toml en %USERPROFILE%\\.codex. Es preferible la autenticación con env_key; no subas secretos al repositorio.', + }, + opencode: { + title: 'Ejemplo de OpenCode', + subtitle: 'opencode.json', + hint: 'Ruta de configuración: ~/.config/opencode/opencode.json (o opencode.jsonc), créala si no existe. Usa los proveedores por defecto (openai/anthropic/google) o un provider_id personalizado. La clave API se puede configurar directamente o con el comando /connect. Esto es un ejemplo, ajusta los modelos y las opciones según necesites.', + }, + }, + customKeyLabel: 'Clave personalizada', + customKeyPlaceholder: 'Introduce tu clave personalizada (mínimo 16 caracteres)', + customKeyHint: 'Solo se permiten letras, números, guiones bajos y guiones. Mínimo 16 caracteres.', + customKeyTooShort: 'La clave personalizada debe tener al menos 16 caracteres', + customKeyInvalidChars: 'La clave personalizada solo puede contener letras, números, guiones bajos y guiones', + customKeyRequired: 'Introduce una clave personalizada', + ipRestriction: 'Restricción por IP', + ipWhitelist: 'Lista de IP permitidas', + ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8', + ipWhitelistHint: 'Una IP o CIDR por línea. Si se define, solo estas IP podrán usar esta clave.', + ipBlacklist: 'Lista de IP bloqueadas', + ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16', + ipBlacklistHint: 'Una IP o CIDR por línea. Estas IP no podrán usar esta clave.', + ipRestrictionEnabled: 'Restricción por IP activada', + ccSwitchNotInstalled: 'CC-Switch no está instalado o su gestor de protocolo no está registrado. Instala CC-Switch primero o copia la clave API a mano.', + ccsClientSelect: { + title: 'Selecciona el cliente', + description: 'Selecciona el tipo de cliente al que importar en CC-Switch:', + claudeCode: 'Claude Code', + claudeCodeDesc: 'Importar como configuración de Claude Code', + geminiCli: 'Gemini CLI', + geminiCliDesc: 'Importar como configuración de Gemini CLI', + }, + // Quota and expiration + quotaLimit: 'Límite de cuota', + quotaAmount: 'Importe de la cuota (USD)', + quotaAmountPlaceholder: 'Introduce el límite de cuota en USD', + quotaAmountHint: 'Define el importe máximo que puede gastar esta clave. 0 = ilimitado.', + quotaUsed: 'Cuota usada', + reset: 'Restablecer', + resetQuotaUsed: 'Poner la cuota usada a 0', + resetQuotaTitle: 'Confirmar el restablecimiento de la cuota', + resetQuotaConfirmMessage: '¿Seguro que quieres poner a 0 la cuota usada (${used}) de la clave «{name}»? Esta acción no se puede deshacer.', + quotaResetSuccess: 'Cuota restablecida correctamente', + failedToResetQuota: 'No se pudo restablecer la cuota', + rateLimitColumn: 'Límite de tasa', + rateLimitSection: 'Límite de tasa', + resetUsage: 'Restablecer', + rateLimit5h: 'Límite de 5 horas (USD)', + rateLimit1d: 'Límite diario (USD)', + rateLimit7d: 'Límite de 7 días (USD)', + rateLimitHint: 'Define el gasto máximo de esta clave dentro de cada ventana de tiempo. 0 = ilimitado.', + rateLimitUsage: 'Consumo del límite de tasa', + resetRateLimitUsage: 'Restablecer el consumo del límite de tasa', + resetRateLimitTitle: 'Confirmar el restablecimiento del límite de tasa', + resetRateLimitConfirmMessage: '¿Seguro que quieres restablecer el consumo del límite de tasa de la clave «{name}»? Se pondrá a cero el consumo de todas las ventanas de tiempo. Esta acción no se puede deshacer.', + rateLimitResetSuccess: 'Consumo del límite de tasa restablecido correctamente', + failedToResetRateLimit: 'No se pudo restablecer el consumo del límite de tasa', + resetNow: 'Se restablece en breve', + expiration: 'Caducidad', + expiresInDays: '{days} días', + extendDays: '+{days} días', + customDate: 'Personalizada', + expirationDate: 'Fecha de caducidad', + expirationDateHint: 'Elige cuándo debe caducar esta clave API.', + currentExpiration: 'Caducidad actual', + expiresAt: 'Caduca', + noExpiration: 'Nunca', + status: { + active: 'Activa', + inactive: 'Inactiva', + quota_exhausted: 'Cuota agotada', + expired: 'Caducada', + }, + }, + + // Usage + usage: { + title: 'Registros de consumo', + description: 'Consulta y analiza tu historial de consumo de la API', + costDetails: 'Desglose del coste', + tokenDetails: 'Desglose de tokens', + cacheTtlOverriddenHint: 'Sustitución del TTL de caché activada', + cacheTtlOverriddenLabel: 'TTL sustituido', + cacheTtlOverridden5m: 'Facturado como 5 min', + cacheTtlOverridden1h: 'Facturado como 1 h', + totalRequests: 'Peticiones totales', + totalTokens: 'Tokens totales', + cacheTotal: 'Caché', + cacheBreakdown: 'Desglose de tokens de caché', + cacheCreationTokensLabel: 'Creación de caché', + cacheReadTokensLabel: 'Lectura de caché', + totalCost: 'Coste total', + standardCost: 'Estándar', + actualCost: 'Real', + accountCost: 'Coste', + userBilled: 'Facturado al usuario', + accountBilled: 'Facturado a la cuenta', + resetNow: 'Ahora', + resetPending: 'Pendiente de actualizar', + accountMultiplier: 'Tarifa de la cuenta', + avgDuration: 'Duración media', + inSelectedRange: 'en el rango seleccionado', + perRequest: 'por petición', + apiKeyFilter: 'Clave API', + allApiKeys: 'Todas las claves API', + timeRange: 'Rango de tiempo', + exportCsv: 'Exportar CSV', + exportExcel: 'Exportar a Excel', + exportingProgress: 'Exportando los datos...', + exportedCount: 'Exportados {current}/{total} registros', + estimatedTime: 'Tiempo restante estimado: {time}', + cancelExport: 'Cancelar la exportación', + exportCancelled: 'Exportación cancelada', + exporting: 'Exportando...', + preparingExport: 'Preparando la exportación...', + model: 'Modelo', + requestedModel: 'Solicitado', + upstreamModel: 'Del proveedor', + sentUpstreamModel: 'Enviado al proveedor', + upstreamResponseModel: 'Respuesta del proveedor', + upstreamModelMismatch: 'El modelo de la respuesta no coincide', + modelVariant: 'Posible variante de versión', + modelMismatch: 'Modelo distinto', + reasoningEffort: 'Esfuerzo de razonamiento', + endpoint: 'Endpoint', + endpointDistribution: 'Reparto por endpoint', + inbound: 'Entrante', + upstream: 'Proveedor', + mapping: 'Correspondencia', + path: 'Ruta', + inboundEndpoint: 'Endpoint de entrada', + upstreamEndpoint: 'Endpoint del proveedor', + type: 'Tipo', + tokens: 'Tokens', + cost: 'Coste', + firstToken: 'Primer token', + duration: 'Duración', + latency: 'Latencia', + latencyFirstToken: 'Primero', + latencyDuration: 'Total', + time: 'Hora', + ws: 'WS', + stream: 'Flujo', + sync: 'Síncrona', + cyber: 'Cibernética', + live: 'En vivo', + unknown: 'Desconocido', + in: 'Ent.', + out: 'Sal.', + cacheHit: 'Acierto de caché', + cacheCreate: 'Creación de caché', + cacheHitRate: 'Tasa de aciertos de caché', + inputTokenPrice: 'Precio de entrada', + outputTokenPrice: 'Precio de salida', + perMillionTokens: '/ 1M tokens', + unitPrice: 'Precio por petición', + imageUnitPrice: 'Precio por imagen', + imageTotalPrice: 'Precio total de imagen', + imageCount: 'Número de imágenes', + imageBillingSize: 'Tamaño facturado', + imageInputSize: 'Tamaño de entrada', + imageOutputSize: 'Tamaño de salida', + imageInputTokens: 'Tokens de entrada de imagen', + imageInputTokenPrice: 'Precio de entrada de imagen', + imageInputCost: 'Coste de entrada de imagen', + imageOutputTokens: 'Tokens de salida de imagen', + imageOutputTokenPrice: 'Precio de salida de imagen', + imageOutputCost: 'Coste de salida de imagen', + imageSizeSource: 'Origen del tamaño', + imageSizeBreakdown: 'Desglose del tamaño', + imageSizeSourceOutput: 'Salida del proveedor', + imageSizeSourceInput: 'Entrada de la petición', + imageSizeSourceDefault: 'Tramo de facturación por defecto', + imageSizeSourceLegacy: 'Registro antiguo', + imageSizeSourceMissing: 'No registrado', + imageSizeNotRecorded: 'no registrado', + imageSizeLegacyUnstandardized: 'antiguo sin normalizar', + imageSizeUnknown: 'desconocido', + cacheRead: 'Lectura', + cacheWrite: 'Escritura', + serviceTier: 'Nivel de servicio', + serviceTierPriority: 'Rápido', + serviceTierFlex: 'Flexible', + serviceTierStandard: 'Estándar', + rate: 'Tarifa', + original: 'Original', + billed: 'Facturado', + noRecords: 'No se encontraron registros de consumo. Prueba a ajustar los filtros.', + failedToLoad: 'No se pudieron cargar los registros de consumo', + noDataToExport: 'No hay datos que exportar', + exportSuccess: 'Datos de consumo exportados correctamente', + exportFailed: 'No se pudieron exportar los datos de consumo', + exportExcelSuccess: 'Datos de consumo exportados correctamente (formato Excel)', + exportExcelFailed: 'No se pudieron exportar los datos de consumo', + imageUnit: ' imágenes', + userAgent: 'User-Agent', + ipGeo: { + fetch: 'Obtener la región', + fetching: 'Obteniendo...', + failed: 'Falló', + private: 'Dirección privada', + refreshTitle: 'Actualizar la información de región', + batchFetch: 'Obtener regiones en bloque', + batchFetching: 'Obteniendo...', + pending: '{count} IP pendientes', + batchFailed: 'No se pudieron obtener las regiones de las IP en bloque', + detailOrg: 'Proveedor de internet', + detailTimezone: 'Zona horaria', + detailAccuracy: 'Precisión', + detailCoordinates: 'Coordenadas', + }, + tabs: { usage: 'Consumo', errors: 'Peticiones con error', ranking: 'Ranking de usuarios' }, + errors: { + time: 'Hora', model: 'Modelo', endpoint: 'Endpoint', status: 'Estado', + category: 'Categoría', platform: 'Plataforma', message: 'Mensaje', + keyName: 'Nombre de la clave', keyDeleted: 'Eliminada', allKeys: 'Todas las claves', + modelPlaceholder: 'Buscar modelo', allCategories: 'Todas las categorías', allStatuses: 'Todos los códigos de estado', + empty: 'No hay peticiones con error', failedToLoad: 'No se pudieron cargar las peticiones con error', + categories: { + auth: 'Falló la autenticación', rate_limit: 'Límite alcanzado', quota: 'Saldo/Suscripción', + invalid_request: 'Petición no válida', service_unavailable: 'Servicio no disponible', + upstream: 'Error del proveedor', internal: 'Error de la plataforma', other: 'Otro', cyber: 'Política cibernética', + }, + detail: { + title: 'Detalle de la petición con error', + responseBody: 'Cuerpo de la respuesta', + upstreamStatus: 'Estado del proveedor', + loadFailed: 'No se pudo cargar el detalle, inténtalo de nuevo', + }, + }, + }, + + // Shared keys for channel monitor (admin + user views) + monitorCommon: { + status: { + operational: 'Operativo', + degraded: 'Degradado', + failed: 'Fallido', + error: 'Error', + unknown: '-' + }, + providers: { + openai: 'OpenAI', + anthropic: 'Anthropic', + gemini: 'Gemini', + grok: 'Grok' + }, + extraModelsHeader: 'Modelos adicionales', + extraModelsEmpty: 'Sin modelos adicionales', + latencyEmpty: '-', + availabilityPrefix: 'Disponibilidad', + dialogLatency: 'Latencia de la conversación', + endpointPing: 'PING al endpoint', + history60pts: 'HISTORIAL ({n} PUNTOS)', + nextUpdateIn: 'SIGUIENTE ACTUALIZACIÓN EN {n} s', + past: 'ANTES', + now: 'AHORA', + maintenancePaused: 'Mantenimiento · línea de tiempo en pausa', + extraModelsCount: '+ {n} modelos', + pollEvery: 'sondeo cada {n} s', + updatedAt: 'Actualizado {time}', + relativeSecondsAgo: 'hace {n} s', + relativeMinutesAgo: 'hace {n} min', + relativeHoursAgo: 'hace {n} h', + relativeDaysAgo: 'hace {n} d' + }, + + // Channel Status (user-facing read-only view) + channelStatus: { + title: 'Estado de los canales', + description: 'Consulta la disponibilidad, la latencia y el estado reciente de los canales', + searchPlaceholder: 'Buscar canales...', + allProviders: 'Todos los proveedores', + loadError: 'No se pudo cargar el estado de los canales', + detailLoadError: 'No se pudo cargar el detalle del canal', + detailTitle: 'Detalle del canal', + closeDetail: 'Cerrar', + windowTab: { + '7d': '7 días', + '15d': '15 días', + '30d': '30 días' + }, + overall: { + operational: 'OPERATIVO', + degraded: 'DEGRADADO', + unavailable: 'NO DISPONIBLE' + }, + columns: { + name: 'Nombre', + provider: 'Proveedor', + groupName: 'Grupo', + primaryModel: 'Modelo principal', + availability7d: 'Disponibilidad 7 d', + latency: 'Latencia (ms)' + }, + detailColumns: { + model: 'Modelo', + latestStatus: 'Último estado', + latestLatency: 'Última latencia (ms)', + availability7d: 'Disponibilidad 7 d', + availability15d: 'Disponibilidad 15 d', + availability30d: 'Disponibilidad 30 d', + avgLatency7d: 'Latencia media 7 d (ms)' + }, + empty: { + title: 'No hay canales disponibles', + description: 'Todavía no se ha configurado ningún canal monitorizado.' + } + }, + + // Available Channels (user-facing) + availableChannels: { + title: 'Canales disponibles', + description: 'Los canales a los que puedes acceder, con los modelos que admiten y sus precios', + searchPlaceholder: 'Buscar canales o modelos...', + empty: 'No hay canales disponibles', + noModels: 'No hay modelos configurados', + noPricing: 'Precios sin configurar', + exclusive: 'Exclusivo', + public: 'Público', + exclusiveTooltip: 'Grupos exclusivos que te ha concedido un administrador', + publicTooltip: 'Grupos abiertos a todos los usuarios', + columns: { + name: 'Canal', + description: 'Descripción', + platform: 'Plataforma', + groups: 'Tus grupos accesibles', + supportedModels: 'Modelos admitidos' + }, + pricing: { + billingMode: 'Modo de facturación', + billingModeToken: 'Por token', + billingModePerRequest: 'Por petición', + billingModeImage: 'Por imagen', + billingModeVideo: 'Por vídeo', + inputPrice: 'Entrada', + outputPrice: 'Salida', + cacheWritePrice: 'Escritura de caché', + cacheReadPrice: 'Lectura de caché', + imageInputPrice: 'Entrada de imagen', + imageOutputPrice: 'Salida de imagen', + perRequestPrice: 'Por petición', + intervals: 'Precios por tramos', + unitPerMillion: '/ 1M tokens', + unitPerRequest: '/ petición' + } + }, + + // Model Plaza (public group/model pricing showcase) + modelPlaza: { + title: 'Catálogo de modelos', + description: 'Explora los modelos disponibles y sus precios por grupo', + loading: 'Cargando...', + empty: 'No hay grupos que mostrar', + loadFailed: 'No se pudo cargar el catálogo de modelos', + noSearchResult: 'No hay modelos que coincidan', + anonymousHint: 'Inicia sesión para ver tus grupos exclusivos y tus tarifas personales', + filters: { + platformLabel: 'Plataforma', + groupLabel: 'Grupo', + rateLabel: 'Tarifa', + modelLabel: 'Modelo', + searchPlaceholder: 'Buscar modelos', + all: 'Todos' + }, + badges: { + exclusive: 'Exclusivo', + subscription: 'Suscripción' + }, + detail: { + noModels: 'Este grupo no tiene modelos configurados', + noPricing: 'Precios sin configurar', + peakNote: 'Horas punta {window}: tarifa de facturación ×{multiplier}' + }, + table: { + model: 'Modelo', + input: 'Entrada', + output: 'Salida', + cache: 'Caché', + cacheWrite: 'Escritura', + cacheRead: 'Lectura', + paidPrice: 'Tu precio (con descuento)', + officialPrice: 'Precio oficial', + rate: 'Tarifa', + unitPerMillion: '$ / 1M tokens', + perUnitRequest: '/ petición', + perUnitImage: '/ imagen', + perRequest: 'Por petición', + perImage: 'Por imagen' + }, + nav: { + login: 'Iniciar sesión', + backToDashboard: 'Volver al panel' + } + }, + + affiliate: { + title: 'Comisiones de afiliados', + description: 'Invita a usuarios nuevos y convierte tus comisiones en saldo de tu cuenta', + yourCode: 'Tu código de afiliado', + inviteLink: 'Enlace de invitación', + copyCode: 'Copiar el código', + copyLink: 'Copiar el enlace', + codeCopied: 'Código de afiliado copiado', + linkCopied: 'Enlace de invitación copiado', + loadFailed: 'No se pudieron cargar los datos de afiliado', + transferFailed: 'No se pudo transferir la cuota de comisiones', + stats: { + rebateRate: 'Mi porcentaje de comisión', + rebateRateHint: 'Lo que ganas cada vez que alguien a quien invitaste recarga', + invitedUsers: 'Usuarios invitados', + availableQuota: 'Comisiones disponibles', + frozenQuota: 'Retenidas', + frozenQuotaHint: 'Comisiones ganadas hace poco pendientes de liberar', + totalQuota: 'Comisiones históricas' + }, + transfer: { + title: 'Transferir las comisiones', + description: 'Pasa las comisiones disponibles al saldo de tu cuenta', + button: 'Transferir al saldo', + transferring: 'Transfiriendo...', + empty: 'No hay comisiones disponibles', + success: 'Se han transferido {amount} a tu saldo' + }, + invitees: { + title: 'Usuarios invitados', + empty: 'Todavía no has invitado a nadie', + columns: { + email: 'Correo electrónico', + username: 'Nombre de usuario', + rebate: 'Comisión', + joinedAt: 'Se unió el' + } + }, + tips: { + title: 'Cómo funciona', + line1: 'Comparte tu código de afiliado o tu enlace de invitación con usuarios nuevos.', + line2: 'Cuando esos usuarios recarguen, recibes el {rate} de la recarga como comisión.', + line3: 'Transfiere las comisiones a tu saldo cuando quieras.', + line4: 'Las comisiones recién ganadas pueden tener un periodo de espera antes de poder transferirse.' + } + }, + + // Redeem + redeem: { + title: 'Canjear un código', + description: 'Introduce tu código de canje para añadir saldo o aumentar la concurrencia', + currentBalance: 'Saldo actual', + concurrency: 'Concurrencia', + requests: 'peticiones', + redeemCodeLabel: 'Código de canje', + redeemCodePlaceholder: 'Introduce tu código de canje', + redeemCodeHint: 'Los códigos de canje distinguen mayúsculas y minúsculas', + redeeming: 'Canjeando...', + redeemButton: 'Canjear el código', + redeemSuccess: '¡Código canjeado correctamente!', + redeemFailed: 'Falló el canje', + added: 'Añadido', + concurrentRequests: 'peticiones simultáneas', + newBalance: 'Saldo nuevo', + newConcurrency: 'Concurrencia nueva', + aboutCodes: 'Sobre los códigos de canje', + codeRule1: 'Cada código solo se puede usar una vez', + codeRule2: 'Los códigos pueden añadir saldo, aumentar la concurrencia o dar acceso de prueba', + codeRule3: 'Contacta con soporte si tienes problemas para canjear un código', + codeRule4: 'El saldo y la concurrencia se actualizan al momento', + recentActivity: 'Actividad reciente', + historyWillAppear: 'Tu historial de canjes aparecerá aquí', + balanceAddedRedeem: 'Saldo añadido (canje)', + balanceAddedAffiliate: 'Saldo añadido (transferencia de comisiones)', + balanceAddedAdmin: 'Saldo añadido (administrador)', + balanceDeductedAdmin: 'Saldo descontado (administrador)', + concurrencyAddedRedeem: 'Concurrencia añadida (canje)', + concurrencyAddedAdmin: 'Concurrencia añadida (administrador)', + concurrencyReducedAdmin: 'Concurrencia reducida (administrador)', + adminAdjustment: 'Ajuste del administrador', + subscriptionAssigned: 'Suscripción asignada', + subscriptionAssignedDesc: 'Se te ha dado acceso a {groupName}', + subscriptionDays: '{days} días', + days: ' días', + codeRedeemSuccess: '¡Código canjeado correctamente!', + failedToRedeem: 'No se pudo canjear el código. Revísalo e inténtalo de nuevo.', + subscriptionRefreshFailed: 'El canje se hizo correctamente, pero no se pudo actualizar el estado de la suscripción.', + pleaseEnterCode: 'Introduce un código de canje' + }, + + // Profile + profile: { + title: 'Ajustes del perfil', + description: 'Gestiona la información y los ajustes de tu cuenta', + accountBalance: 'Saldo de la cuenta', + concurrencyLimit: 'Límite de concurrencia', + rpmLimit: 'Límite de RPM', + rpmUnlimited: 'Ilimitado', + memberSince: 'Miembro desde', + overviewTitle: 'Resumen de la cuenta', + overviewDescription: 'Consulta de un vistazo el estado de la cuenta, el origen de tus datos de perfil y las acciones habituales.', + basicsTitle: 'Perfil y foto', + basicsDescription: 'Mantén al día los datos públicos de tu perfil y tu foto.', + linkedProfileSources: 'Origen de los datos del perfil', + linkedProfileSourcesDescription: 'Algunos datos del perfil pueden mantenerse sincronizados con métodos de inicio de sesión externos.', + securityTitle: 'Ajustes de seguridad', + securityDescription: 'La contraseña, la verificación en dos pasos y las alertas están en la columna de la derecha.', + administrator: 'Administrador', + user: 'Usuario', + username: 'Nombre de usuario', + email: 'Correo electrónico', + status: 'Estado', + role: 'Rol', + enterUsername: 'Introduce el nombre de usuario', + editProfile: 'Editar el perfil', + updateProfile: 'Actualizar el perfil', + updating: 'Actualizando...', + updateSuccess: 'Perfil actualizado correctamente', + updateFailed: 'No se pudo actualizar el perfil', + usernameRequired: 'El nombre de usuario es obligatorio', + changePassword: 'Cambiar la contraseña', + currentPassword: 'Contraseña actual', + newPassword: 'Contraseña nueva', + confirmNewPassword: 'Confirmar la contraseña nueva', + passwordHint: 'La contraseña debe tener al menos 8 caracteres', + changingPassword: 'Cambiando...', + changePasswordButton: 'Cambiar la contraseña', + passwordsNotMatch: 'Las contraseñas nuevas no coinciden', + passwordTooShort: 'La contraseña debe tener al menos 8 caracteres', + passwordChangeSuccess: 'Contraseña cambiada correctamente', + passwordChangeFailed: 'No se pudo cambiar la contraseña', + // TOTP 2FA + totp: { + title: 'Verificación en dos pasos (2FA)', + description: 'Refuerza la seguridad de tu cuenta con Google Authenticator o apps similares', + enabled: 'Activada', + enabledAt: 'Activada el', + notEnabled: 'No activada', + notEnabledHint: 'Activa la verificación en dos pasos para reforzar la seguridad de tu cuenta', + enable: 'Activar', + disable: 'Desactivar', + featureDisabled: 'Función no disponible', + featureDisabledHint: 'El administrador no ha activado la verificación en dos pasos', + setupTitle: 'Configurar la verificación en dos pasos', + setupStep1: 'Escanea el código QR de abajo con tu app de autenticación', + setupStep2: 'Introduce el código de 6 dígitos que muestra la app', + manualEntry: '¿No puedes escanear? Introduce la clave a mano:', + enterCode: 'Introduce el código de 6 dígitos', + verify: 'Verificar', + setupFailed: 'No se pudo obtener la información de configuración', + verifyFailed: 'Código no válido, inténtalo de nuevo', + enableSuccess: 'Verificación en dos pasos activada', + disableTitle: 'Desactivar la verificación en dos pasos', + disableWarning: 'Una vez desactivada, ya no necesitarás un código de verificación para iniciar sesión. Esto puede reducir la seguridad de tu cuenta.', + enterPassword: 'Introduce tu contraseña actual para confirmar', + confirmDisable: 'Confirmar la desactivación', + disableSuccess: 'Verificación en dos pasos desactivada', + disableFailed: 'No se pudo desactivar, revisa tu contraseña', + loginTitle: 'Verificación en dos pasos', + loginHint: 'Introduce el código de 6 dígitos de tu app de autenticación', + loginFailed: 'La verificación falló, inténtalo de nuevo', + // New translations for email verification + verifyEmailFirst: 'Verifica primero tu correo electrónico', + verifyPasswordFirst: 'Verifica primero tu identidad', + emailCode: 'Código de verificación por correo', + enterEmailCode: 'Introduce el código de 6 dígitos', + sendCode: 'Enviar el código', + codeSent: 'Código de verificación enviado a tu correo', + sendCodeFailed: 'No se pudo enviar el código de verificación' + }, + passkey: { + title: 'Passkeys', + description: 'Usa Face ID, Touch ID, Windows Hello o una llave de seguridad para iniciar sesión sin contraseña.', + add: 'Añadir una passkey', + continue: 'Crear la passkey', + name: 'Nombre de la passkey', + namePlaceholder: 'Por ejemplo, Touch ID del MacBook', + passwordPlaceholder: 'Introduce tu contraseña actual para confirmar', + empty: 'Todavía no hay ninguna passkey registrada.', + synced: 'Sincronizada', + createdAt: 'Creada el {date}', + lastUsed: 'Último uso el {date}', + featureDisabled: 'El administrador no ha configurado las passkeys.', + unsupported: 'Este navegador o dispositivo no admite passkeys.', + loadFailed: 'No se pudieron cargar las passkeys.', + added: 'Passkey añadida.', + addFailed: 'No se pudo añadir la passkey.', + renamePrompt: 'Introduce un nombre nuevo para esta passkey', + renamed: 'Passkey renombrada.', + renameFailed: 'No se pudo renombrar la passkey.', + deleteTitle: 'Eliminar la passkey', + deleteConfirm: '¿Eliminar «{name}»? Ya no podrás iniciar sesión con ella.', + deleted: 'Passkey eliminada.', + deleteFailed: 'No se pudo eliminar la passkey.' + }, + balanceNotify: { + title: 'Aviso de saldo bajo', + description: 'Envía una alerta por correo cuando el saldo de la cuenta baje del umbral', + enabled: 'Activar el aviso de saldo bajo', + threshold: 'Umbral personalizado', + thresholdHint: 'Déjalo vacío para usar el valor por defecto del sistema', + thresholdPlaceholder: 'Introduce el importe', + systemDefault: 'Valor por defecto del sistema', + extraEmails: 'Correos de aviso', + extraEmailsHint: 'Tienes que añadir y verificar una dirección de correo para recibir avisos de saldo bajo', + primaryEmail: 'Principal', + noExtraEmails: 'No hay correos de aviso adicionales', + enterEmail: 'Introduce la dirección de correo', + addEmail: 'Añadir el correo', + emailPlaceholder: 'Introduce la dirección de correo', + sendCode: 'Enviar el código', + resend: 'Reenviar', + codeSent: 'Código de verificación enviado', + codeSentTo: 'Código enviado a {email}', + enterCode: 'Introduce el código de verificación', + codePlaceholder: 'Código de 6 dígitos', + verify: 'Verificar', + emailAdded: 'Correo añadido', + emailRemoved: 'Correo eliminado', + verifySuccess: 'Correo añadido correctamente', + removeEmail: 'Quitar', + removeSuccess: 'Correo eliminado', + emailDuplicate: 'Este correo ya existe', + maxEmailsReached: 'Has alcanzado el número máximo de correos de aviso', + unverified: 'Sin verificar', + verified: 'Verificado', + }, + avatar: { + title: 'Foto de perfil', + description: 'Sube una imagen de perfil. Las imágenes estáticas se comprimen a 20 KB antes de guardarse.', + uploadAction: 'Subir una imagen', + uploadHint: 'Las imágenes estáticas se comprimen a 20 KB cuando es posible. Los GIF ya deben ocupar 20 KB o menos.', + uploadRequired: 'Sube primero una imagen de perfil', + saveSuccess: 'Foto de perfil actualizada', + deleteSuccess: 'Foto de perfil eliminada', + invalidType: 'Elige un archivo de imagen', + gifTooLarge: 'Los GIF de perfil ya deben ocupar 20 KB o menos', + compressTooLarge: 'No se puede comprimir esta imagen por debajo de 20 KB. Prueba con una imagen más pequeña.', + compressFailed: 'No se pudo comprimir la imagen seleccionada.', + readFailed: 'No se pudo leer la imagen seleccionada.', + emptyDeleteHint: 'La foto de perfil ya está vacía', + }, + authBindings: { + title: 'Métodos de inicio de sesión vinculados', + description: 'Consulta las vinculaciones actuales y conecta otro proveedor a esta cuenta.', + bindAction: 'Vincular {providerName}', + bindSuccess: 'Cuenta vinculada correctamente', + emailPlaceholder: 'Introduce la dirección de correo', + codePlaceholder: 'Introduce el código de verificación', + passwordPlaceholder: 'Define una contraseña de acceso', + replaceEmailPasswordPlaceholder: 'Introduce la contraseña actual', + sendCodeAction: 'Enviar el código', + manageEmailAction: 'Gestionar el correo', + hideEmailFormAction: 'Ocultar el formulario de correo', + confirmEmailBindAction: 'Vincular el correo', + confirmEmailReplaceAction: 'Sustituir el correo principal', + codeSentTo: 'Código enviado a {email}', + replaceSuccess: 'Correo principal actualizado', + unbindAction: 'Desvincular', + unbindSuccess: '{providerName} desvinculado', + boundCount: '{count} registros vinculados', + status: { + bound: 'Vinculado', + notBound: 'Sin vincular', + }, + providers: { + email: 'Correo electrónico', + linuxdo: 'LinuxDo', + dingtalk: 'DingTalk', + oidc: '{providerName}', + wechat: 'WeChat', + }, + notes: { + emailManagedFromProfile: 'El correo principal se gestiona en el formulario del perfil', + canUnbind: 'Puedes desvincular este método de inicio de sesión', + bindAnotherBeforeUnbind: 'Vincula otro método de inicio de sesión antes de desvincular este', + }, + source: { + avatar: 'La foto de perfil se sincroniza actualmente desde {providerName}', + username: 'El nombre visible se sincroniza actualmente desde {providerName}', + }, + } + }, + + // Empty States + empty: { + noData: 'No se encontraron datos' + }, + + // Table + table: { + expandActions: 'Mostrar más acciones', + collapseActions: 'Ocultar acciones' + }, + + // Pagination + pagination: { + showing: 'Mostrando', + to: 'a', + of: 'de', + results: 'resultados', + page: 'Página', + pageOf: 'Página {page} de {total}', + previous: 'Anterior', + next: 'Siguiente', + perPage: 'Por página', + goToPage: 'Ir a la página {page}', + jumpTo: 'Ir a', + jumpPlaceholder: 'Página', + jumpAction: 'Ir' + }, + + // Errors + errors: { + somethingWentWrong: 'Algo salió mal', + pageNotFound: 'Página no encontrada', + unauthorized: 'No autorizado', + forbidden: 'Prohibido', + serverError: 'Error del servidor', + networkError: 'Error de red', + timeout: 'Se agotó el tiempo de la petición', + tryAgain: 'Inténtalo de nuevo' + }, + + // Dates + dates: { + today: 'Hoy', + yesterday: 'Ayer', + thisWeek: 'Esta semana', + lastWeek: 'La semana pasada', + thisMonth: 'Este mes', + lastMonth: 'El mes pasado', + last24Hours: 'Últimas 24 horas', + last7Days: 'Últimos 7 días', + last14Days: 'Últimos 14 días', + last30Days: 'Últimos 30 días', + custom: 'Personalizado', + startDate: 'Fecha de inicio', + endDate: 'Fecha de fin', + apply: 'Aplicar', + selectDateRange: 'Selecciona el rango de fechas' + }, + + // Admin +} diff --git a/frontend/src/i18n/locales/es/index.ts b/frontend/src/i18n/locales/es/index.ts new file mode 100644 index 00000000000..ac2867bfda9 --- /dev/null +++ b/frontend/src/i18n/locales/es/index.ts @@ -0,0 +1,17 @@ +import landing from './landing' +import common from './common' +import dashboard from './dashboard' +import channelMonitorV2 from './channelMonitorV2' +import batchImage from './batchImage' +import admin from './admin' +import misc from './misc' + +export default { + ...landing, + ...common, + ...dashboard, + ...channelMonitorV2, + ...batchImage, + admin, + ...misc, +} diff --git a/frontend/src/i18n/locales/es/landing.ts b/frontend/src/i18n/locales/es/landing.ts new file mode 100644 index 00000000000..4325b6c725a --- /dev/null +++ b/frontend/src/i18n/locales/es/landing.ts @@ -0,0 +1,257 @@ +export default { + batchImageGuide: { + title: 'Generación de imágenes por lotes', + description: 'Envía varios prompts en un mismo trabajo y descarga las imágenes generadas cuando termine' + }, + // Home Page + home: { + viewOnGithub: 'Ver en GitHub', + viewDocs: 'Ver la documentación', + docs: 'Documentación', + switchToLight: 'Cambiar al modo claro', + switchToDark: 'Cambiar al modo oscuro', + dashboard: 'Panel', + login: 'Iniciar sesión', + getStarted: 'Empezar', + goToDashboard: 'Ir al panel', + // User-focused value proposition + heroSubtitle: 'Una sola clave, todos los modelos de IA', + heroDescription: 'Sin necesidad de gestionar varias suscripciones. Accede a Claude, GPT, Gemini y más con una única clave API', + tags: { + subscriptionToApi: 'De suscripción a API', + stickySession: 'Sesión persistente', + realtimeBilling: 'Pago por uso' + }, + // Pain points section + painPoints: { + title: '¿Te suena?', + items: { + expensive: { + title: 'Suscripciones caras', + desc: 'Pagar varias suscripciones de IA que se van sumando cada mes' + }, + complex: { + title: 'Caos de cuentas', + desc: 'Gestionar cuentas y claves API repartidas entre distintas plataformas' + }, + unstable: { + title: 'Cortes de servicio', + desc: 'Cuentas individuales que llegan a su límite y frenan tu trabajo' + }, + noControl: { + title: 'Sin control del gasto', + desc: 'No poder ver en qué se va tu dinero ni limitar el uso de tu equipo' + } + } + }, + // Solutions section + solutions: { + title: 'Nosotros resolvemos esos problemas', + subtitle: 'Tres pasos sencillos para acceder a la IA sin dolores de cabeza' + }, + features: { + unifiedGateway: 'Acceso con un clic', + unifiedGatewayDesc: 'Consigue una única clave API para usar todos los modelos de IA conectados. Sin trámites por separado.', + multiAccount: 'Siempre disponible', + multiAccountDesc: 'Reparto inteligente entre varias cuentas de proveedor con conmutación automática. Adiós a los errores.', + balanceQuota: 'Paga lo que uses', + balanceQuotaDesc: 'Facturación por consumo con límites de cuota. Visibilidad total del gasto de tu equipo.' + }, + // Comparison section + comparison: { + title: '¿Por qué elegirnos?', + headers: { + feature: 'Comparación', + official: 'Suscripciones oficiales', + us: 'Nuestra plataforma' + }, + items: { + pricing: { + feature: 'Precio', + official: 'Cuota mensual fija, pagas aunque no la uses', + us: 'Pagas solo por lo que consumes' + }, + models: { + feature: 'Elección de modelos', + official: 'Un solo proveedor', + us: 'Cambia de modelo libremente' + }, + management: { + feature: 'Gestión de cuentas', + official: 'Gestionar cada servicio por separado', + us: 'Una clave unificada, un solo panel' + }, + stability: { + feature: 'Estabilidad', + official: 'Límites de una sola cuenta', + us: 'Grupo de varias cuentas con conmutación automática' + }, + control: { + feature: 'Control del consumo', + official: 'No disponible', + us: 'Cuotas y estadísticas detalladas' + } + } + }, + providers: { + title: 'Modelos de IA compatibles', + description: 'Una API, muchas opciones', + supported: 'Disponible', + soon: 'Próximamente', + claude: 'Claude', + gemini: 'Gemini', + antigravity: 'Antigravity', + more: 'Más' + }, + // CTA section + cta: { + title: '¿Listo para empezar?', + description: 'Regístrate ahora y recibe saldo de prueba gratis para experimentar el acceso a la IA sin complicaciones', + button: 'Registrarse gratis' + }, + footer: { + allRightsReserved: 'Todos los derechos reservados.' + } + }, + + // Key Usage Query Page + keyUsage: { + title: 'Consumo de la clave API', + subtitle: 'Introduce tu clave API para ver el gasto y el estado de consumo en tiempo real', + placeholder: 'sk-ant-mirror-xxxxxxxxxxxx', + query: 'Consultar', + querying: 'Consultando...', + privacyNote: 'Tu clave se procesa localmente en el navegador y no se guarda en ningún sitio', + dateRange: 'Rango de fechas:', + dateRangeToday: 'Hoy', + dateRange7d: '7 días', + dateRange30d: '30 días', + dateRange90d: '90 días', + dateRangeCustom: 'Personalizado', + apply: 'Aplicar', + used: 'Usado', + detailInfo: 'Información detallada', + tokenStats: 'Estadísticas de tokens', + dailyDetail: 'Detalle diario', + modelStats: 'Estadísticas de consumo por modelo', + // Table headers + date: 'Fecha', + model: 'Modelo', + requests: 'Peticiones', + inputTokens: 'Tokens de entrada', + outputTokens: 'Tokens de salida', + cacheCreationTokens: 'Creación de caché', + cacheReadTokens: 'Lectura de caché', + cacheWriteTokens: 'Escritura de caché', + totalTokens: 'Tokens totales', + cost: 'Coste', + // Status + quotaMode: 'Modo de cuota de la clave', + walletBalance: 'Saldo del monedero', + // Ring card titles + totalQuota: 'Cuota total', + limit5h: 'Límite de 5 horas', + limitDaily: 'Límite diario', + limit7d: 'Límite de 7 días', + limitWeekly: 'Límite semanal', + limitMonthly: 'Límite mensual', + // Detail rows + remainingQuota: 'Cuota restante', + expiresAt: 'Caduca el', + todayExpires: '(caduca hoy)', + daysLeft: '({days} días)', + usedQuota: 'Cuota usada', + resetNow: 'Se restablece en breve', + subscriptionType: 'Tipo de suscripción', + subscriptionExpires: 'La suscripción caduca', + // Usage stat cells + todayRequests: 'Peticiones de hoy', + todayInputTokens: 'Entrada de hoy', + todayOutputTokens: 'Salida de hoy', + todayTokens: 'Tokens de hoy', + todayCacheCreation: 'Creación de caché de hoy', + todayCacheRead: 'Lectura de caché de hoy', + todayCost: 'Coste de hoy', + rpmTpm: 'RPM / TPM', + totalRequests: 'Peticiones totales', + totalInputTokens: 'Entrada total', + totalOutputTokens: 'Salida total', + totalTokensLabel: 'Tokens totales', + totalCacheCreation: 'Creación de caché total', + totalCacheRead: 'Lectura de caché total', + totalCost: 'Coste total', + avgDuration: 'Duración media', + // Messages + enterApiKey: 'Introduce una clave API', + querySuccess: 'Consulta realizada', + queryFailed: 'La consulta falló', + queryFailedRetry: 'La consulta falló, inténtalo de nuevo más tarde', + noDailyUsage: 'No hay datos de consumo diario', + }, + + // Setup Wizard + setup: { + title: 'Instalación de Sub2API', + description: 'Configura tu instancia de Sub2API', + database: { + title: 'Configuración de la base de datos', + description: 'Conecta con tu base de datos PostgreSQL', + host: 'Servidor', + port: 'Puerto', + username: 'Usuario', + password: 'Contraseña', + databaseName: 'Nombre de la base de datos', + sslMode: 'Modo SSL', + passwordPlaceholder: 'Contraseña', + ssl: { + disable: 'Desactivado', + require: 'Obligatorio', + verifyCa: 'Verificar CA', + verifyFull: 'Verificación completa' + } + }, + redis: { + title: 'Configuración de Redis', + description: 'Conecta con tu servidor Redis', + host: 'Servidor', + port: 'Puerto', + username: 'Usuario (opcional)', + password: 'Contraseña (opcional)', + database: 'Base de datos', + usernamePlaceholder: 'Déjalo vacío para el usuario por defecto', + passwordPlaceholder: 'Contraseña', + enableTls: 'Activar TLS', + enableTlsHint: 'Usar TLS al conectar con Redis (certificados de CA públicas)' + }, + admin: { + title: 'Cuenta de administrador', + description: 'Crea tu cuenta de administrador', + email: 'Correo electrónico', + password: 'Contraseña', + confirmPassword: 'Confirmar la contraseña', + passwordPlaceholder: 'Mínimo 8 caracteres', + confirmPasswordPlaceholder: 'Confirma la contraseña', + passwordMismatch: 'Las contraseñas no coinciden' + }, + ready: { + title: 'Listo para instalar', + description: 'Revisa tu configuración y completa la instalación', + database: 'Base de datos', + redis: 'Redis', + adminEmail: 'Correo del administrador' + }, + status: { + testing: 'Probando...', + success: 'Conexión correcta', + testConnection: 'Probar la conexión', + installing: 'Instalando...', + completeInstallation: 'Completar la instalación', + completed: '¡Instalación completada!', + redirecting: 'Redirigiendo a la página de inicio de sesión...', + restarting: 'El servicio se está reiniciando, espera un momento...', + timeout: 'El reinicio del servicio está tardando más de lo esperado. Actualiza la página manualmente.' + } + }, + + // Common +} diff --git a/frontend/src/i18n/locales/es/misc.ts b/frontend/src/i18n/locales/es/misc.ts new file mode 100644 index 00000000000..071909a3284 --- /dev/null +++ b/frontend/src/i18n/locales/es/misc.ts @@ -0,0 +1,621 @@ +export default { + + // Subscription Progress (Header component) + subscriptionProgress: { + title: 'Mis suscripciones', + viewDetails: 'Ver los detalles de la suscripción', + activeCount: '{count} suscripción(es) activa(s)', + daily: 'Diario', + weekly: 'Semanal', + monthly: 'Mensual', + daysRemaining: 'Quedan {days} días', + expired: 'Caducada', + expiresToday: 'Caduca hoy', + expiresTomorrow: 'Caduca mañana', + viewAll: 'Ver todas las suscripciones', + noSubscriptions: 'No hay suscripciones activas', + unlimited: 'Ilimitado' + }, + + // Version Badge + version: { + currentVersion: 'Versión actual', + latestVersion: 'Última versión', + upToDate: 'Estás usando la última versión.', + updateAvailable: '¡Hay una versión nueva disponible!', + releaseNotes: 'Notas de la versión', + noReleaseNotes: 'Sin notas de la versión', + viewUpdate: 'Ver la actualización', + viewRelease: 'Ver la publicación', + viewChangelog: 'Ver el registro de cambios', + refresh: 'Actualizar', + sourceMode: 'Compilación desde el código', + sourceModeHint: 'Compilación desde el código, usa git pull para actualizar', + updateNow: 'Actualizar ahora', + updating: 'Actualizando...', + updateComplete: 'Actualización completada', + updateFailed: 'Falló la actualización', + restartRequired: 'Reinicia el servicio para aplicar la actualización', + restartNow: 'Reiniciar ahora', + restarting: 'Reiniciando...', + retry: 'Reintentar', + rollback: 'Volver a una versión anterior', + rollbackSelectVersion: 'Selecciona la versión a la que volver (últimas 3 versiones)', + rollbackConfirm: 'Volver a {version}', + rollbackWarning: + 'Volver atrás descarga la versión seleccionada y reemplaza el binario actual. Después hay que reiniciar el servicio.', + rollingBack: 'Volviendo atrás...', + rollbackComplete: 'Se volvió a la versión anterior', + rollbackFailed: 'No se pudo volver atrás', + manualRollbackCommand: 'Vuelta atrás manual', + copyCommand: 'Copiar', + copied: 'Copiado', + noRollbackVersions: 'No hay versiones disponibles para volver atrás', + loadVersionsFailed: 'No se pudieron cargar las versiones', + rollbackSourceHint: 'La vuelta atrás en línea no está disponible en compilaciones desde el código', + deployScript: 'Script', + deployDocker: 'Docker', + dockerEditCompose: 'Edita la etiqueta de la imagen en docker-compose.yml', + dockerRecreate: 'Vuelve a crear el contenedor' + }, + + // Recharge / Subscription Page + purchase: { + title: 'Recargar / Suscripción', + description: 'Recarga saldo o compra una suscripción desde la página integrada', + openInNewTab: 'Abrir en una pestaña nueva', + notEnabledTitle: 'Función no activada', + notEnabledDesc: 'El administrador no ha activado el acceso a recargas y suscripciones. Contacta con él.', + notConfiguredTitle: 'URL de recarga / suscripción sin configurar', + notConfiguredDesc: + 'El administrador activó el acceso pero no ha configurado una URL de recarga o suscripción. Contacta con él.' + }, + + // Custom Page (iframe embed) + customPage: { + title: 'Página personalizada', + openInNewTab: 'Abrir en una pestaña nueva', + notFoundTitle: 'Página no encontrada', + notFoundDesc: 'Esta página personalizada no existe o ha sido eliminada.', + notConfiguredTitle: 'URL de la página sin configurar', + notConfiguredDesc: 'La URL de esta página personalizada no está bien configurada.', + tableOfContents: 'Contenido', + copyCode: 'Copiar', + copiedCode: 'Copiado', + copyCodeFailed: 'Falló' + }, + + // Announcements Page + announcements: { + title: 'Anuncios', + description: 'Consulta los anuncios del sistema', + unreadOnly: 'Mostrar solo los no leídos', + markRead: 'Marcar como leído', + markAllRead: 'Marcar todos como leídos', + viewAll: 'Ver todos los anuncios', + markedAsRead: 'Marcado como leído', + allMarkedAsRead: 'Todos los anuncios marcados como leídos', + newCount: '{count} anuncio nuevo | {count} anuncios nuevos', + readAt: 'Leído el', + read: 'Leído', + unread: 'Sin leer', + startsAt: 'Empieza el', + endsAt: 'Termina el', + empty: 'No hay anuncios', + emptyUnread: 'No hay anuncios sin leer', + total: 'anuncios', + emptyDescription: 'En este momento no hay anuncios del sistema', + readStatus: 'Ya has leído este anuncio', + markReadHint: 'Pulsa «Marcar como leído» para marcar este anuncio' + }, + + // User Subscriptions Page + userSubscriptions: { + title: 'Mis suscripciones', + description: 'Consulta tus planes de suscripción y su consumo', + noActiveSubscriptions: 'No hay suscripciones activas', + noActiveSubscriptionsDesc: + 'No tienes ninguna suscripción activa. Contacta con el administrador para conseguir una.', + failedToLoad: 'No se pudieron cargar las suscripciones', + status: { + active: 'Activa', + expired: 'Caducada', + revoked: 'Revocada' + }, + usage: 'Consumo', + expires: 'Caduca', + noExpiration: 'Sin caducidad', + unlimited: 'Ilimitado', + unlimitedDesc: 'Esta suscripción no tiene límites de consumo', + daily: 'Diario', + weekly: 'Semanal', + monthly: 'Mensual', + daysRemaining: 'Quedan {days} días', + expiresOn: 'Caduca el {date}', + resetIn: 'Se restablece en {time}', + quotaEndsIn: 'La cuota termina en {time}', + windowNotActive: 'Esperando el primer uso', + usageOf: '{used} de {limit}' + }, + + // Onboarding Tour + onboarding: { + restartTour: 'Repetir el tour de bienvenida', + dontShowAgain: 'No volver a mostrar', + dontShowAgainTitle: 'Cerrar el tour de bienvenida para siempre', + confirmDontShow: '¿Seguro que no quieres volver a ver el tour de bienvenida?\n\nPuedes repetirlo cuando quieras desde el menú de usuario de la esquina superior derecha.', + confirmExit: '¿Seguro que quieres salir del tour de bienvenida? Puedes repetirlo cuando quieras desde el menú de la esquina superior derecha.', + interactiveHint: 'Pulsa Intro o haz clic para continuar', + navigation: { + flipPage: 'Pasar de página', + exit: 'Salir' + }, + // Admin tour steps + admin: { + welcome: { + title: '👋 Te damos la bienvenida a Sub2API', + description: '

Sub2API es una potente plataforma de pasarela de servicios de IA que te ayuda a gestionar y distribuir servicios de IA con facilidad.

🎯 Funciones principales:

  • 📦 Gestión de grupos - Crea niveles de servicio (VIP, prueba gratuita, etc.)
  • 🔗 Grupo de cuentas - Conecta varias cuentas de proveedores de IA
  • 🔑 Reparto de claves - Genera claves API independientes para tus usuarios
  • 💰 Control de facturación - Gestión flexible de tarifas y cuotas

Completemos la configuración inicial en 3 minutos →

', + nextBtn: 'Empezar la configuración 🚀', + prevBtn: 'Omitir' + }, + groupManage: { + title: '📦 Paso 1: Gestión de grupos', + description: '

¿Qué es un grupo?

Los grupos son el concepto central de Sub2API, como un «paquete de servicio»:

  • 🎯 Cada grupo puede contener varias cuentas de proveedor
  • 💰 Cada grupo tiene su propio multiplicador de facturación
  • 👥 Se puede marcar como público o exclusivo

💡 Ejemplo: Puedes crear los grupos «VIP Premium» (tarifa alta) y «Prueba gratuita» (tarifa baja)

👉 Pulsa «Gestión de grupos» en la barra lateral izquierda

' + }, + createGroup: { + title: '➕ Crear un grupo nuevo', + description: '

Vamos a crear tu primer grupo.

📝 Consejo: Te recomendamos crear primero un grupo de prueba para familiarizarte con el proceso

👉 Pulsa el botón «Crear grupo»

' + }, + groupName: { + title: '✏️ 1. Nombre del grupo', + description: '

Ponle a tu grupo un nombre fácil de identificar.

💡 Sugerencias de nombre:
  • «Grupo de prueba» - Para hacer pruebas
  • «VIP Premium» - Servicio de alta calidad
  • «Prueba gratuita» - Versión de prueba

Pulsa «Siguiente» cuando termines

', + nextBtn: 'Siguiente' + }, + groupPlatform: { + title: '🤖 2. Elegir la plataforma', + description: '

Elige la plataforma de IA que admite este grupo.

📌 Guía de plataformas:
  • Anthropic - Modelos Claude
  • OpenAI - Modelos GPT
  • Google - Modelos Gemini

Un grupo solo puede tener una plataforma

', + nextBtn: 'Siguiente' + }, + groupMultiplier: { + title: '💰 3. Multiplicador de tarifa', + description: '

Define el multiplicador de facturación para controlar cuánto pagan tus usuarios.

⚙️ Reglas de facturación:
  • 1.0 - Precio original (precio de coste)
  • 1.5 - El usuario consume 1 $ y se le cobra 1,5 $
  • 2.0 - El usuario consume 1 $ y se le cobra 2 $
  • 0.8 - Modo subvencionado (con pérdidas)

Te recomendamos poner 1.0 en el grupo de prueba

', + nextBtn: 'Siguiente' + }, + groupExclusive: { + title: '🔒 4. Grupo exclusivo (opcional)', + description: '

Controla la visibilidad del grupo y los permisos de acceso.

🔐 Guía de permisos:
  • Desactivado - Grupo público, visible para todos los usuarios
  • Activado - Grupo exclusivo, solo para usuarios concretos

💡 Casos de uso: Exclusivo VIP, pruebas internas, clientes especiales

', + nextBtn: 'Siguiente' + }, + groupSubmit: { + title: '✅ Guardar el grupo', + description: '

Confirma la información y pulsa crear para guardar el grupo.

⚠️ Atención: El tipo de plataforma no se puede cambiar después de crearlo, pero el resto de ajustes sí se pueden editar en cualquier momento

📌 Siguiente paso: Cuando esté creado, añadiremos cuentas de proveedor a este grupo

👉 Pulsa el botón «Crear»

' + }, + accountManage: { + title: '🔗 Paso 2: Añadir una cuenta', + description: '

¡Muy bien! Grupo creado correctamente 🎉

Ahora añade cuentas de servicios de IA para que la plataforma pueda dar servicio de verdad.

🔑 Para qué sirven las cuentas:
  • Conectar con los servicios de IA (Claude, GPT, etc.)
  • Un grupo puede contener varias cuentas (reparto de carga)
  • Admite métodos OAuth y clave de sesión

👉 Pulsa «Gestión de cuentas» en la barra lateral izquierda

' + }, + createAccount: { + title: '➕ Añadir una cuenta nueva', + description: '

Pulsa el botón para empezar a añadir tu primera cuenta de proveedor.

💡 Consejo: Te recomendamos el método OAuth: es más seguro y no hay que extraer claves a mano

👉 Pulsa el botón «Añadir cuenta»

' + }, + accountName: { + title: '✏️ 1. Nombre de la cuenta', + description: '

Ponle a la cuenta un nombre fácil de identificar.

💡 Sugerencias de nombre: «Claude principal», «GPT respaldo 1», «Cuenta de prueba», etc.

', + nextBtn: 'Siguiente' + }, + accountPlatform: { + title: '🤖 2. Elegir la plataforma', + description: '

Elige la plataforma del proveedor de servicio para esta cuenta.

⚠️ Importante: La plataforma debe coincidir con la del grupo que acabas de crear

', + nextBtn: 'Siguiente' + }, + accountType: { + title: '🔐 3. Método de autorización', + description: '

Elige el método de autorización de la cuenta.

✅ Recomendado: método OAuth
  • No hay que extraer claves a mano
  • Más seguro y con renovación automática
  • Funciona con Claude Code y OAuth de ChatGPT
📌 Método de clave de sesión
  • Hay que extraerla a mano desde el navegador
  • Puede necesitar actualizaciones periódicas
  • Para plataformas que no admiten OAuth
', + nextBtn: 'Siguiente' + }, + accountPriority: { + title: '⚖️ 4. Prioridad (opcional)', + description: '

Define la prioridad de uso de la cuenta.

📊 Reglas de prioridad:
  • Número más bajo = más prioridad
  • El sistema usa primero las cuentas con el valor más bajo
  • Misma prioridad = selección aleatoria

💡 Caso de uso: Pon un valor bajo en la cuenta principal y valores altos en las de respaldo

', + nextBtn: 'Siguiente' + }, + accountGroups: { + title: '🎯 5. Asignar grupos', + description: '

¡Paso clave! Asigna la cuenta al grupo que acabas de crear.

⚠️ Recordatorio importante:
  • Hay que seleccionar al menos un grupo
  • Las cuentas sin asignar no se pueden usar
  • Una cuenta puede asignarse a varios grupos

💡 Consejo: Selecciona el grupo de prueba que acabas de crear

', + nextBtn: 'Siguiente' + }, + accountSubmit: { + title: '✅ Guardar la cuenta', + description: '

Confirma la información y pulsa guardar.

📌 Flujo de OAuth:
  • Al pulsar guardar te llevará a la página del proveedor
  • Completa el inicio de sesión y la autorización allí
  • Volverás automáticamente cuando termine

📌 Siguiente paso: Cuando la cuenta esté añadida, crearemos una clave API

👉 Pulsa el botón «Guardar»

' + }, + keyManage: { + title: '🔑 Paso 3: Generar una clave', + description: '

¡Enhorabuena! Cuenta configurada 🎉

Último paso: generar una clave API para comprobar que el servicio funciona bien.

🔑 Para qué sirve la clave API:
  • Es la credencial para usar los servicios de IA
  • Cada clave está vinculada a un grupo
  • Se le pueden poner cuota y caducidad
  • Permite estadísticas de consumo independientes

👉 Pulsa «Claves API» en la barra lateral izquierda

' + }, + createKey: { + title: '➕ Crear una clave', + description: '

Pulsa el botón para crear tu primera clave API.

💡 Consejo: Cópiala y guárdala en cuanto se cree: la clave solo se muestra una vez

👉 Pulsa el botón «Crear clave»

' + }, + keyName: { + title: '✏️ 1. Nombre de la clave', + description: '

Ponle a la clave un nombre fácil de gestionar.

💡 Sugerencias de nombre: «Clave de prueba», «Producción», «Móvil», etc.

', + nextBtn: 'Siguiente' + }, + keyGroup: { + title: '🎯 2. Elegir el grupo', + description: '

Selecciona el grupo que acabas de configurar.

📌 El grupo determina:
  • Qué cuentas puede usar esta clave
  • Qué multiplicador de facturación se aplica
  • Si es una clave exclusiva

💡 Consejo: Selecciona el grupo de prueba que acabas de crear

', + nextBtn: 'Siguiente' + }, + keySubmit: { + title: '🎉 Generar y copiar', + description: '

El sistema generará una clave API completa cuando pulses crear.

⚠️ Recordatorio importante:
  • La clave solo se muestra una vez, cópiala de inmediato
  • Si la pierdes hay que generar otra
  • Guárdala a buen recaudo y no la compartas
🚀 Siguientes pasos:
  • Copia la clave sk-xxx generada
  • Úsala en cualquier cliente compatible con OpenAI
  • ¡Empieza a disfrutar de los servicios de IA!

👉 Pulsa el botón «Crear»

' + } + }, + // User tour steps + user: { + welcome: { + title: '👋 Te damos la bienvenida a Sub2API', + description: '

¡Hola! Bienvenido a la plataforma de servicios de IA Sub2API.

🎯 Inicio rápido:

  • 🔑 Crea una clave API
  • 📋 Copia la clave en tu aplicación
  • 🚀 Empieza a usar los servicios de IA

Solo 1 minuto, empecemos →

', + nextBtn: 'Empezar 🚀', + prevBtn: 'Omitir' + }, + keyManage: { + title: '🔑 Gestión de claves API', + description: '

Gestiona aquí todas tus claves de acceso a la API.

📌 ¿Qué es una clave API?
Una clave API es tu credencial para acceder a los servicios de IA, como una llave que permite a tu aplicación usar las capacidades de IA.

👉 Pulsa para entrar en la página de claves

' + }, + createKey: { + title: '➕ Crear una clave nueva', + description: '

Pulsa el botón para crear tu primera clave API.

💡 Consejo: La clave solo se muestra una vez al crearla, asegúrate de copiarla y guardarla

👉 Pulsa «Crear clave»

' + }, + keyName: { + title: '✏️ Nombre de la clave', + description: '

Ponle a tu clave un nombre fácil de identificar.

💡 Ejemplos: «Mi primera clave», «Para pruebas», etc.

', + nextBtn: 'Siguiente' + }, + keyGroup: { + title: '🎯 Elegir el grupo', + description: '

Selecciona el grupo de servicio que te asignó el administrador.

📌 Sobre los grupos:
Cada grupo puede tener una calidad de servicio y una tarifa distintas, elige según lo que necesites.

', + nextBtn: 'Siguiente' + }, + keySubmit: { + title: '🎉 Completar la creación', + description: '

Pulsa para confirmar y crear tu clave API.

⚠️ Importante:
  • Copia la clave (sk-xxx) en cuanto se cree
  • Solo se muestra una vez; si la pierdes hay que generar otra

🚀 Cómo usarla:
Configura la clave en cualquier cliente compatible con OpenAI (como ChatBox, OpenCat, etc.) ¡y empieza a usarla!

👉 Pulsa el botón «Crear»

' + } + } + }, + + // Payment System + payment: { + title: 'Recargar / Suscripción', + amountLabel: 'Importe', + paymentAmount: 'Importe del pago', + creditedBalance: 'Saldo abonado', + quickAmounts: 'Importes rápidos', + customAmount: 'Importe personalizado', + enterAmount: 'Introduce el importe', + paymentMethod: 'Método de pago', + fee: 'Comisión', + actualPay: 'Pago real', + createOrder: 'Confirmar el pago', + methods: { + easypay: 'EasyPay', + alipay: 'Alipay', + wxpay: 'WeChat Pay', + stripe: 'Stripe', + airwallex: 'Airwallex', + card: 'Tarjeta', + link: 'Link', + alipay_direct: 'Alipay (directo)', + wxpay_direct: 'WeChat Pay (directo)', + }, + status: { + pending: 'Pendiente', + paid: 'Pagado', + recharging: 'Recargando', + completed: 'Completado', + expired: 'Caducado', + cancelled: 'Cancelado', + failed: 'Fallido', + refund_requested: 'Reembolso solicitado', + refunding: 'Reembolsando', + refund_pending: 'Reembolso pendiente', + refunded: 'Reembolsado', + partially_refunded: 'Reembolsado parcialmente', + refund_failed: 'Falló el reembolso', + }, + qr: { + scanToPay: 'Escanea para pagar', + scanAlipay: 'Pago con QR de Alipay', + scanWxpay: 'Pago con QR de WeChat', + scanAlipayHint: 'Abre Alipay en tu teléfono y escanea el código QR para pagar', + scanWxpayHint: 'Abre WeChat en tu teléfono y escanea el código QR para pagar', + payInNewWindow: 'Completa el pago en la ventana nueva', + payInNewWindowHint: 'La página de pago se abrió en una ventana nueva. Completa el pago allí y vuelve a esta página.', + openPayWindow: 'Volver a abrir la página de pago', + expiresIn: 'Caduca en', + expired: 'Pedido caducado', + expiredDesc: 'Este pedido ha caducado. Crea uno nuevo.', + cancelled: 'Pedido cancelado', + cancelledDesc: 'Has cancelado este pago.', + waitingPayment: 'Esperando el pago...', + cancelOrder: 'Cancelar el pedido', + alipayOpening: 'Abriendo Alipay', + alipayContinueInApp: 'Completa el pago en Alipay', + alipayWaitingHint: 'El servidor confirmará el pago y actualizará esta página automáticamente', + alipayFallbackTitle: 'Alipay no se abrió', + alipayFallbackHint: 'Prueba a abrir Alipay de nuevo, o guarda el código QR y escanéalo desde el álbum de fotos de Alipay', + reopenAlipay: 'Abrir Alipay otra vez', + saveQRCode: 'Guardar el código QR', + alipaySaveAndScanHint: 'Guarda el código QR, abre el escáner de Alipay y selecciónalo desde tu álbum de fotos', + }, + orders: { + title: 'Mis pedidos', + empty: 'Todavía no hay pedidos', + orderId: 'ID del pedido', + orderNo: 'N.º de pedido', + amount: 'Importe', + payAmount: 'Pagado', + creditedAmount: 'Importe abonado', + fee: 'Comisión', + baseAmount: 'Importe base', + includedInPayAmount: 'incluida en el importe pagado', + status: 'Estado', + paymentMethod: 'Método de pago', + createdAt: 'Creado', + cancel: 'Cancelar el pedido', + userId: 'ID del usuario', + orderType: 'Tipo de pedido', + actions: 'Acciones', + requestRefund: 'Solicitar un reembolso', + }, + result: { + success: 'Pago realizado', + subscriptionSuccess: 'Suscripción realizada', + processing: 'Procesando el pago', + processingHint: 'Todavía falta confirmar el pago. Esta página se actualizará automáticamente.', + failed: 'El pago falló', + backToRecharge: 'Volver a la recarga', + viewOrders: 'Ver los pedidos', + }, + currentBalance: 'Saldo actual', + groupFallback: 'Grupo n.º {id}', + rechargeAccount: 'Cuenta a recargar', + activeSubscription: 'Suscripción activa', + noActiveSubscription: 'No hay suscripción activa', + tabTopUp: 'Recargar', + tabSubscribe: 'Suscribirse', + noPlans: 'No hay planes de suscripción disponibles', + notAvailable: 'La recarga no está disponible en este momento', + confirmSubscription: 'Confirmar la suscripción', + confirmCancel: '¿Seguro que quieres cancelar este pedido?', + amountTooLow: 'El importe mínimo es {min}', + amountTooHigh: 'El importe máximo es {max}', + amountNoMethod: 'No hay ningún método de pago disponible para este importe', + rechargeRatePreview: 'Tipo de cambio actual: 1 CNY = {usd} USD', + refundReason: 'Motivo del reembolso', + refundReasonPlaceholder: 'Describe el motivo de tu reembolso', + stripeLoadFailed: 'No se pudo cargar el componente de pago. Actualiza la página e inténtalo de nuevo.', + stripeMissingParams: 'Falta el ID del pedido o el client secret', + stripeNotConfigured: 'Stripe no está configurado', + airwallexLoadFailed: 'No se pudo cargar el componente de pago de Airwallex. Actualiza la página e inténtalo de nuevo.', + airwallexMissingParams: 'Faltan parámetros del pago de Airwallex', + errors: { + tooManyPending: 'Demasiados pedidos pendientes (máximo {max}). Completa o cancela los que ya tienes.', + cancelRateLimited: 'Demasiadas cancelaciones. Inténtalo de nuevo más tarde.', + wechatH5NotAuthorized: 'Este comercio no tiene activado el pago H5 de WeChat. Abre esta página dentro de WeChat para continuar.', + wechatPaymentMpNotConfigured: 'Este sitio no ha completado la configuración de pago MP/JSAPI de WeChat, así que el pago dentro de la app no está disponible ahora mismo.', + wechatJsapiUnavailable: 'No se pudo invocar el pago de WeChat en este entorno. Vuelve a abrir esta página dentro de WeChat e inténtalo de nuevo.', + wechatJsapiFailed: 'El pago de WeChat no se completó. Prueba a invocarlo de nuevo o cambia al pago con QR.', + wechatUnavailable: 'El pago con WeChat no está disponible temporalmente. Inténtalo de nuevo más tarde.', + wechatOpenInWeChatHint: 'Abre la página actual dentro de WeChat, o cambia al pago con QR de WeChat de escritorio.', + wechatScanOnDesktopHint: 'En escritorio, usa el escáner de WeChat para pagar; en móvil, vuelve a abrir la página dentro de WeChat.', + wechatSwitchBrowserHint: 'Cambia al pago con QR de WeChat de escritorio, o vuelve a abrir esta página en un navegador externo e inténtalo de nuevo.', + mobilePaymentFallbackToQr: 'Este comercio no tiene activado el pago móvil. El proceso cambió automáticamente al pago con QR.', + alipayDesktopUnavailable: 'El flujo de Alipay de escritorio no pudo generar un código QR.', + alipayDesktopQrHint: 'Alipay de escritorio debería mostrar un código QR. Actualiza la página e inténtalo de nuevo, o comprueba que no se bloqueó la página de pago.', + alipayMobileUnavailable: 'Esta página no pudo pasar el control a Alipay.', + alipayMobileOpenHint: 'Permite que la página actual abra la app de Alipay, o inténtalo desde el navegador del sistema.', + // Structured error codes (reason strings from backend ApplicationError) + PAYMENT_DISABLED: 'El sistema de pagos está desactivado.', + USER_INACTIVE: 'Tu cuenta está desactivada.', + BALANCE_PAYMENT_DISABLED: 'La recarga de saldo está desactivada.', + INVALID_AMOUNT: 'Importe no válido.', + INVALID_INPUT: 'Petición no válida.', + PLAN_NOT_AVAILABLE: 'El plan no existe o ya no está disponible.', + GROUP_NOT_FOUND: 'El grupo de la suscripción ya no está disponible.', + GROUP_TYPE_MISMATCH: 'El grupo no es de tipo suscripción.', + TOO_MANY_PENDING: 'Demasiados pedidos pendientes (máximo {max}). Completa o cancela los que ya tienes.', + DAILY_LIMIT_EXCEEDED: 'Has alcanzado el límite diario de recarga. Te queda: {remaining}.', + PAYMENT_GATEWAY_ERROR: 'El método de pago no está disponible.', + NO_AVAILABLE_INSTANCE: 'No hay ningún canal de pago disponible ahora mismo.', + PAYMENT_PROVIDER_MISCONFIGURED: 'El proveedor de pagos está mal configurado. Contacta con un administrador.', + WXPAY_CONFIG_MISSING_KEY: 'A la configuración de WeChat Pay le falta una clave obligatoria: {key}.', + WXPAY_CONFIG_INVALID_KEY_LENGTH: 'La longitud de {key} en WeChat Pay no es válida (se esperaban {expected} bytes y se recibieron {actual}).', + WXPAY_CONFIG_INVALID_KEY: 'El valor {key} de WeChat Pay está mal formado. Comprueba que copiaste todo el contenido PEM.', + PENDING_ORDERS: 'Este proveedor tiene pedidos pendientes. Espera a que se completen antes de hacer cambios.', + PAYMENT_PROVIDER_CONFLICT: 'Ya hay otra instancia de proveedor activa dando servicio a este método de pago. Desactívala antes de continuar.', + CANCEL_RATE_LIMITED: 'Demasiadas cancelaciones. Inténtalo de nuevo más tarde.', + NOT_FOUND: 'Pedido no encontrado.', + FORBIDDEN: 'No tienes permiso para este pedido.', + CONFLICT: 'El estado del pedido ha cambiado. Actualiza la página.', + INVALID_ORDER_TYPE: 'Solo los pedidos de saldo pueden solicitar un reembolso.', + INVALID_STATUS: 'El estado actual del pedido no permite esta operación.', + BALANCE_NOT_ENOUGH: 'El importe del reembolso supera el saldo.', + REFUND_AMOUNT_EXCEEDED: 'El importe del reembolso supera el importe recargado.', + REFUND_FAILED: 'El reembolso falló.', + }, + airwallexPay: 'Pago con Airwallex', + stripePay: 'Pagar ahora', + stripeSuccessProcessing: 'Pago realizado, procesando tu pedido...', + stripePopup: { + redirecting: 'Redirigiendo a la página de pago...', + loadingQr: 'Cargando el código QR de WeChat Pay...', + timeout: 'Se agotó el tiempo esperando las credenciales de pago, inténtalo de nuevo', + qrFailed: 'No se pudo obtener el código QR de WeChat Pay', + }, + subscribeNow: 'Suscribirse ahora', + renewNow: 'Renovar', + selectPlan: 'Elegir el plan', + planFeatures: 'Características', + planCard: { + rate: 'Tarifa', + peakRate: 'Tarifa máxima', + dailyLimit: 'Diario', + weeklyLimit: 'Semanal', + monthlyLimit: 'Mensual', + quota: 'Cuota', + unlimited: 'Ilimitado', + models: 'Modelos', + }, + days: 'días', + weeks: 'semanas', + months: 'meses', + years: 'años', + oneMonth: '1 mes', + oneYear: '1 año', + perMonth: 'mes', + perYear: 'año', + admin: { + tabs: { + overview: 'Resumen', + orders: 'Pedidos', + channels: 'Canales', + plans: 'Planes', + }, + todayRevenue: 'Ingresos de hoy', + totalRevenue: 'Ingresos totales', + todayOrders: 'Pedidos de hoy', + orderCount: 'Número de pedidos', + avgAmount: 'Importe medio', + revenue: 'Ingresos', + dailyRevenue: 'Ingresos diarios', + paymentDistribution: 'Reparto por método de pago', + colUser: 'Usuario', + topUsers: 'Usuarios que más gastan', + noData: 'Sin datos', + days: 'días', + weeks: 'semanas', + months: 'meses', + searchOrders: 'Buscar pedidos...', + allStatuses: 'Todos los estados', + allPaymentTypes: 'Todos los métodos de pago', + allOrderTypes: 'Todos los tipos de pedido', + orderDetail: 'Detalle del pedido', + orderType: 'Tipo de pedido', + orders: 'Pedidos', + balanceOrder: 'Recarga de saldo', + subscriptionOrder: 'Suscripción', + paidAt: 'Pagado el', + completedAt: 'Completado el', + expiresAt: 'Caduca el', + feeRate: 'Porcentaje de comisión', + refund: 'Reembolso', + refundOrder: 'Reembolsar el pedido', + refundAmount: 'Importe del reembolso', + maxRefundable: 'Máximo reembolsable', + refundReason: 'Motivo del reembolso', + refundReasonPlaceholder: 'Introduce el motivo del reembolso', + confirmRefund: 'Confirmar el reembolso', + refundSuccess: 'Reembolso realizado', + refundPending: 'El reembolso está pendiente de confirmación de la pasarela', + queryRefundStatus: 'Consultar el estado del reembolso', + refundInfo: 'Información del reembolso', + refundEnabled: 'Reembolsos activados', + allowUserRefund: 'Permitir que el usuario pida el reembolso', + alreadyRefunded: 'Ya reembolsado', + deductBalance: 'Descontar del saldo', + deductBalanceHint: 'Resta el importe recargado del saldo del usuario', + userBalance: 'Saldo del usuario', + orderAmount: 'Importe del pedido', + insufficientBalance: 'Saldo insuficiente: se descontará hasta 0 $', + noDeduction: 'NO se descontará del saldo del usuario', + forceRefund: 'Forzar el reembolso (ignorar la comprobación de saldo)', + orderCancelled: 'Pedido cancelado', + retry: 'Reintentar', + retrySuccess: 'Reintento correcto', + approveRefund: 'Aprobar el reembolso', + retryRefund: 'Reintentar el reembolso', + refundRequestInfo: 'Información de la solicitud de reembolso', + refundRequestedAt: 'Solicitado el', + refundRequestedBy: 'Solicitado por', + refundRequestReason: 'Motivo de la solicitud', + auditLogs: 'Registros de auditoría', + operator: 'Operador', + channelName: 'Nombre del canal', + channelDescription: 'Descripción del canal', + createChannel: 'Crear un canal', + editChannel: 'Editar el canal', + deleteChannel: 'Eliminar el canal', + deleteChannelConfirm: '¿Seguro que quieres eliminar este canal?', + planName: 'Nombre del plan', + planDescription: 'Descripción del plan', + createPlan: 'Crear un plan', + editPlan: 'Editar el plan', + deletePlan: 'Eliminar el plan', + deletePlanConfirm: '¿Seguro que quieres eliminar este plan?', + originalPrice: 'Precio original', + price: 'Precio', + currency: 'Etiqueta de moneda', + currencyPlaceholder: 'p. ej. USD / MXN / EUR', + currencyHint: 'Código ISO de 3 letras que se muestra junto al precio solo de forma visual; déjalo vacío para ocultarlo, no afecta a la facturación', + subscriptionCnyPayPreview: 'Previsualización del cargo en el canal CNY: {amount}', + subscriptionCnyPayPreviewWithFee: '(comisión del {feeRate}% incluida: {total})', + validity: 'Validez', + validityUnit: 'Unidad de validez', + sortOrder: 'Orden', + forSale: 'A la venta', + onSale: 'A la venta', + offSale: 'Retirado', + group: 'Grupo', + groupId: 'ID del grupo', + features: 'Características', + featuresHint: 'Una característica por línea', + featuresPlaceholder: 'Introduce las características del plan...', + providerManagement: 'Gestión de proveedores', + providerManagementDesc: 'Gestiona las instancias de proveedores de pago', + createProvider: 'Crear un proveedor', + editProvider: 'Editar el proveedor', + deleteProvider: 'Eliminar el proveedor', + deleteProviderConfirm: '¿Seguro que quieres eliminar este proveedor?', + providerName: 'Nombre del proveedor', + providerKey: 'Clave del proveedor', + selectProviderKey: 'Selecciona la clave del proveedor', + providerConfig: 'Configuración del proveedor', + noProviders: 'No hay proveedores configurados', + noProvidersHint: 'Crea una instancia de proveedor para empezar a aceptar pagos', + supportedTypes: 'Métodos de pago admitidos', + supportedTypesHint: 'Selecciona los métodos de pago que admite este proveedor', + rateMultiplier: 'Multiplicador de tarifa', + dashboardTitle: 'Panel de pagos', + dashboardDesc: 'Analítica e información de los pedidos de recarga', + daySuffix: 'd', + paymentConfigTitle: 'Configuración de pagos', + paymentConfigDesc: 'Configura los proveedores de pago y sus ajustes', + plansPageTitle: 'Planes de suscripción', + plansPageDesc: 'Gestiona la configuración de los planes de suscripción', + tabPlanConfig: 'Configuración de planes', + tabUserSubs: 'Suscripciones de usuarios', + selectGroup: 'Selecciona un grupo', + groupRequired: 'Selecciona un grupo de suscripción', + priceRequired: 'El precio debe ser mayor que 0', + validityRequired: 'La validez debe ser mayor que 0', + groupMissing: 'Falta', + groupInfo: 'Información del grupo', + platform: 'Plataforma', + rateMultiplierLabel: 'Tarifa', + dailyLimit: 'Límite diario', + weeklyLimit: 'Límite semanal', + monthlyLimit: 'Límite mensual', + unlimited: 'Ilimitado', + searchUserSubs: 'Buscar suscripciones de usuarios...', + daily: 'D', + weekly: 'S', + monthly: 'M', + subsStatus: { + active: 'Activa', + expired: 'Caducada', + revoked: 'Revocada', + }, + }, + }, + +} diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index 49a205fc4b3..dad2a2e79d5 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -842,7 +842,7 @@ function fmtNum(val: number | null | undefined): string { function formatDate(iso: string | null | undefined): string { if (!iso) return '-' const d = new Date(iso) - const loc = locale.value === 'zh' ? 'zh-CN' : 'en-US' + const loc = locale.value === 'zh' ? 'zh-CN' : locale.value === 'es' ? 'es-ES' : 'en-US' return d.toLocaleDateString(loc, { year: 'numeric', month: 'long', day: 'numeric' }) }