| <template> |
| <AppLayout> |
| <div class="mx-auto max-w-4xl space-y-6"> |
| |
| <div v-if="loading" class="flex items-center justify-center py-12"> |
| <div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div> |
| </div> |
| |
| |
| <form v-else @submit.prevent="saveSettings" class="space-y-6"> |
| |
| <div class="sticky top-0 z-10 overflow-x-auto settings-tabs-scroll"> |
| <nav class="settings-tabs"> |
| <button |
| v-for="tab in settingsTabs" |
| :key="tab.key" |
| type="button" |
| :class="['settings-tab', activeTab === tab.key && 'settings-tab-active']" |
| @click="activeTab = tab.key" |
| > |
| <span class="settings-tab-icon"> |
| <Icon :name="tab.icon" size="sm" /> |
| </span> |
| <span>{{ t(`admin.settings.tabs.${tab.key}`) }}</span> |
| </button> |
| </nav> |
| </div> |
| |
| |
| <div v-show="activeTab === 'security'" class="space-y-6"> |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.adminApiKey.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.adminApiKey.description') }} |
| </p> |
| </div> |
| <div class="space-y-4 p-6"> |
| |
| <div |
| class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20" |
| > |
| <div class="flex items-start"> |
| <Icon |
| name="exclamationTriangle" |
| size="md" |
| class="mt-0.5 flex-shrink-0 text-amber-500" |
| /> |
| <p class="ml-3 text-sm text-amber-700 dark:text-amber-300"> |
| {{ t('admin.settings.adminApiKey.securityWarning') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <div v-if="adminApiKeyLoading" class="flex items-center gap-2 text-gray-500"> |
| <div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div> |
| {{ t('common.loading') }} |
| </div> |
| |
| |
| <div v-else-if="!adminApiKeyExists" class="flex items-center justify-between"> |
| <span class="text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.adminApiKey.notConfigured') }} |
| </span> |
| <button |
| type="button" |
| @click="createAdminApiKey" |
| :disabled="adminApiKeyOperating" |
| class="btn btn-primary btn-sm" |
| > |
| <svg |
| v-if="adminApiKeyOperating" |
| class="mr-1 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ |
| adminApiKeyOperating |
| ? t('admin.settings.adminApiKey.creating') |
| : t('admin.settings.adminApiKey.create') |
| }} |
| </button> |
| </div> |
| |
| |
| <div v-else class="space-y-4"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.adminApiKey.currentKey') }} |
| </label> |
| <code |
| class="rounded bg-gray-100 px-2 py-1 font-mono text-sm text-gray-900 dark:bg-dark-700 dark:text-gray-100" |
| > |
| {{ adminApiKeyMasked }} |
| </code> |
| </div> |
| <div class="flex gap-2"> |
| <button |
| type="button" |
| @click="regenerateAdminApiKey" |
| :disabled="adminApiKeyOperating" |
| class="btn btn-secondary btn-sm" |
| > |
| {{ |
| adminApiKeyOperating |
| ? t('admin.settings.adminApiKey.regenerating') |
| : t('admin.settings.adminApiKey.regenerate') |
| }} |
| </button> |
| <button |
| type="button" |
| @click="deleteAdminApiKey" |
| :disabled="adminApiKeyOperating" |
| class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400" |
| > |
| {{ t('admin.settings.adminApiKey.delete') }} |
| </button> |
| </div> |
| </div> |
| |
| |
| <div |
| v-if="newAdminApiKey" |
| class="space-y-3 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20" |
| > |
| <p class="text-sm font-medium text-green-700 dark:text-green-300"> |
| {{ t('admin.settings.adminApiKey.keyWarning') }} |
| </p> |
| <div class="flex items-center gap-2"> |
| <code |
| class="flex-1 select-all break-all rounded border border-green-300 bg-white px-3 py-2 font-mono text-sm dark:border-green-700 dark:bg-dark-800" |
| > |
| {{ newAdminApiKey }} |
| </code> |
| <button |
| type="button" |
| @click="copyNewKey" |
| class="btn btn-primary btn-sm flex-shrink-0" |
| > |
| {{ t('admin.settings.adminApiKey.copyKey') }} |
| </button> |
| </div> |
| <p class="text-xs text-green-600 dark:text-green-400"> |
| {{ t('admin.settings.adminApiKey.usage') }} |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-show="activeTab === 'gateway'" class="space-y-6"> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.overloadCooldown.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.overloadCooldown.description') }} |
| </p> |
| </div> |
| <div class="space-y-5 p-6"> |
| <div v-if="overloadCooldownLoading" class="flex items-center gap-2 text-gray-500"> |
| <div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div> |
| {{ t('common.loading') }} |
| </div> |
| |
| <template v-else> |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.overloadCooldown.enabled') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.overloadCooldown.enabledHint') }} |
| </p> |
| </div> |
| <Toggle v-model="overloadCooldownForm.enabled" /> |
| </div> |
| |
| <div |
| v-if="overloadCooldownForm.enabled" |
| class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.overloadCooldown.cooldownMinutes') }} |
| </label> |
| <input |
| v-model.number="overloadCooldownForm.cooldown_minutes" |
| type="number" |
| min="1" |
| max="120" |
| class="input w-32" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.overloadCooldown.cooldownMinutesHint') }} |
| </p> |
| </div> |
| </div> |
| |
| <div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"> |
| <button |
| type="button" |
| @click="saveOverloadCooldownSettings" |
| :disabled="overloadCooldownSaving" |
| class="btn btn-primary btn-sm" |
| > |
| <svg |
| v-if="overloadCooldownSaving" |
| class="mr-1 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ overloadCooldownSaving ? t('common.saving') : t('common.save') }} |
| </button> |
| </div> |
| </template> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.streamTimeout.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.streamTimeout.description') }} |
| </p> |
| </div> |
| <div class="space-y-5 p-6"> |
| |
| <div v-if="streamTimeoutLoading" class="flex items-center gap-2 text-gray-500"> |
| <div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div> |
| {{ t('common.loading') }} |
| </div> |
| |
| <template v-else> |
| |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.streamTimeout.enabled') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.streamTimeout.enabledHint') }} |
| </p> |
| </div> |
| <Toggle v-model="streamTimeoutForm.enabled" /> |
| </div> |
| |
| |
| <div |
| v-if="streamTimeoutForm.enabled" |
| class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.streamTimeout.action') }} |
| </label> |
| <select v-model="streamTimeoutForm.action" class="input w-64"> |
| <option value="temp_unsched">{{ t('admin.settings.streamTimeout.actionTempUnsched') }}</option> |
| <option value="error">{{ t('admin.settings.streamTimeout.actionError') }}</option> |
| <option value="none">{{ t('admin.settings.streamTimeout.actionNone') }}</option> |
| </select> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.streamTimeout.actionHint') }} |
| </p> |
| </div> |
| |
| |
| <div v-if="streamTimeoutForm.action === 'temp_unsched'"> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.streamTimeout.tempUnschedMinutes') }} |
| </label> |
| <input |
| v-model.number="streamTimeoutForm.temp_unsched_minutes" |
| type="number" |
| min="1" |
| max="60" |
| class="input w-32" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.streamTimeout.tempUnschedMinutesHint') }} |
| </p> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.streamTimeout.thresholdCount') }} |
| </label> |
| <input |
| v-model.number="streamTimeoutForm.threshold_count" |
| type="number" |
| min="1" |
| max="10" |
| class="input w-32" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.streamTimeout.thresholdCountHint') }} |
| </p> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.streamTimeout.thresholdWindowMinutes') }} |
| </label> |
| <input |
| v-model.number="streamTimeoutForm.threshold_window_minutes" |
| type="number" |
| min="1" |
| max="60" |
| class="input w-32" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.streamTimeout.thresholdWindowMinutesHint') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"> |
| <button |
| type="button" |
| @click="saveStreamTimeoutSettings" |
| :disabled="streamTimeoutSaving" |
| class="btn btn-primary btn-sm" |
| > |
| <svg |
| v-if="streamTimeoutSaving" |
| class="mr-1 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ streamTimeoutSaving ? t('common.saving') : t('common.save') }} |
| </button> |
| </div> |
| </template> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.rectifier.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.rectifier.description') }} |
| </p> |
| </div> |
| <div class="space-y-5 p-6"> |
| |
| <div v-if="rectifierLoading" class="flex items-center gap-2 text-gray-500"> |
| <div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div> |
| {{ t('common.loading') }} |
| </div> |
| |
| <template v-else> |
| |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.rectifier.enabled') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.rectifier.enabledHint') }} |
| </p> |
| </div> |
| <Toggle v-model="rectifierForm.enabled" /> |
| </div> |
| |
| |
| <div |
| v-if="rectifierForm.enabled" |
| class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ |
| t('admin.settings.rectifier.thinkingSignature') |
| }}</label> |
| <p class="text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.rectifier.thinkingSignatureHint') }} |
| </p> |
| </div> |
| <Toggle v-model="rectifierForm.thinking_signature_enabled" /> |
| </div> |
| |
| |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ |
| t('admin.settings.rectifier.thinkingBudget') |
| }}</label> |
| <p class="text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.rectifier.thinkingBudgetHint') }} |
| </p> |
| </div> |
| <Toggle v-model="rectifierForm.thinking_budget_enabled" /> |
| </div> |
| </div> |
| |
| |
| <div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"> |
| <button |
| type="button" |
| @click="saveRectifierSettings" |
| :disabled="rectifierSaving" |
| class="btn btn-primary btn-sm" |
| > |
| <svg |
| v-if="rectifierSaving" |
| class="mr-1 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ rectifierSaving ? t('common.saving') : t('common.save') }} |
| </button> |
| </div> |
| </template> |
| </div> |
| </div> |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.betaPolicy.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.betaPolicy.description') }} |
| </p> |
| </div> |
| <div class="space-y-5 p-6"> |
| |
| <div v-if="betaPolicyLoading" class="flex items-center gap-2 text-gray-500"> |
| <div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div> |
| {{ t('common.loading') }} |
| </div> |
| |
| <template v-else> |
| |
| <div |
| v-for="rule in betaPolicyForm.rules" |
| :key="rule.beta_token" |
| class="rounded-lg border border-gray-200 p-4 dark:border-dark-600" |
| > |
| <div class="mb-3 flex items-center gap-2"> |
| <span class="text-sm font-medium text-gray-900 dark:text-white"> |
| {{ getBetaDisplayName(rule.beta_token) }} |
| </span> |
| <span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400"> |
| {{ rule.beta_token }} |
| </span> |
| </div> |
| |
| <div class="grid grid-cols-2 gap-4"> |
| |
| <div> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.betaPolicy.action') }} |
| </label> |
| <Select |
| :modelValue="rule.action" |
| @update:modelValue="rule.action = $event as any" |
| :options="betaPolicyActionOptions" |
| /> |
| </div> |
| |
| |
| <div> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.betaPolicy.scope') }} |
| </label> |
| <Select |
| :modelValue="rule.scope" |
| @update:modelValue="rule.scope = $event as any" |
| :options="betaPolicyScopeOptions" |
| /> |
| </div> |
| </div> |
| |
| |
| <div v-if="rule.action === 'block'" class="mt-3"> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.betaPolicy.errorMessage') }} |
| </label> |
| <input |
| v-model="rule.error_message" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.betaPolicy.errorMessagePlaceholder')" |
| /> |
| <p class="mt-1 text-xs text-gray-400 dark:text-gray-500"> |
| {{ t('admin.settings.betaPolicy.errorMessageHint') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"> |
| <button |
| type="button" |
| @click="saveBetaPolicySettings" |
| :disabled="betaPolicySaving" |
| class="btn btn-primary btn-sm" |
| > |
| <svg |
| v-if="betaPolicySaving" |
| class="mr-1 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ betaPolicySaving ? t('common.saving') : t('common.save') }} |
| </button> |
| </div> |
| </template> |
| </div> |
| </div> |
| |
| </div> |
| |
| |
| <div v-show="activeTab === 'security'" class="space-y-6"> |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.registration.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.description') }} |
| </p> |
| </div> |
| <div class="space-y-5 p-6"> |
| |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.registration.enableRegistration') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.enableRegistrationHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.registration_enabled" /> |
| </div> |
| |
| |
| <div |
| class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.registration.emailVerification') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.emailVerificationHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.email_verify_enabled" /> |
| </div> |
| |
| |
| <div class="border-t border-gray-100 pt-4 dark:border-dark-700"> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.registration.emailSuffixWhitelist') |
| }}</label> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.emailSuffixWhitelistHint') }} |
| </p> |
| <div |
| class="mt-3 rounded-lg border border-gray-300 bg-white p-2 dark:border-dark-500 dark:bg-dark-700" |
| > |
| <div class="flex flex-wrap items-center gap-2"> |
| <span |
| v-for="suffix in registrationEmailSuffixWhitelistTags" |
| :key="suffix" |
| class="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-dark-600 dark:text-gray-200" |
| > |
| <span class="text-gray-400 dark:text-gray-500">@</span> |
| <span>{{ suffix }}</span> |
| <button |
| type="button" |
| class="rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-dark-500 dark:hover:text-white" |
| @click="removeRegistrationEmailSuffixWhitelistTag(suffix)" |
| > |
| <Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" /> |
| </button> |
| </span> |
| |
| <div |
| class="flex min-w-[220px] flex-1 items-center gap-1 rounded border border-transparent px-2 py-1 focus-within:border-primary-300 dark:focus-within:border-primary-700" |
| > |
| <span class="font-mono text-sm text-gray-400 dark:text-gray-500">@</span> |
| <input |
| v-model="registrationEmailSuffixWhitelistDraft" |
| type="text" |
| class="w-full bg-transparent text-sm font-mono text-gray-900 outline-none placeholder:text-gray-400 dark:text-white dark:placeholder:text-gray-500" |
| :placeholder="t('admin.settings.registration.emailSuffixWhitelistPlaceholder')" |
| @input="handleRegistrationEmailSuffixWhitelistDraftInput" |
| @keydown="handleRegistrationEmailSuffixWhitelistDraftKeydown" |
| @blur="commitRegistrationEmailSuffixWhitelistDraft" |
| @paste="handleRegistrationEmailSuffixWhitelistPaste" |
| /> |
| </div> |
| </div> |
| </div> |
| <p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.emailSuffixWhitelistInputHint') }} |
| </p> |
| </div> |
| |
| |
| <div |
| class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.registration.promoCode') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.promoCodeHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.promo_code_enabled" /> |
| </div> |
| |
| |
| <div |
| class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.registration.invitationCode') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.invitationCodeHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.invitation_code_enabled" /> |
| </div> |
| |
| <div |
| v-if="form.email_verify_enabled" |
| class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.registration.passwordReset') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.passwordResetHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.password_reset_enabled" /> |
| </div> |
| |
| <div |
| v-if="form.email_verify_enabled && form.password_reset_enabled" |
| class="border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.registration.frontendUrl') }} |
| </label> |
| <input |
| v-model="form.frontend_url" |
| type="url" |
| class="input" |
| :placeholder="t('admin.settings.registration.frontendUrlPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.frontendUrlHint') }} |
| </p> |
| </div> |
| |
| |
| <div |
| class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.registration.totp') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.registration.totpHint') }} |
| </p> |
| |
| <p |
| v-if="!form.totp_encryption_key_configured" |
| class="mt-2 text-sm text-amber-600 dark:text-amber-400" |
| > |
| {{ t('admin.settings.registration.totpKeyNotConfigured') }} |
| </p> |
| </div> |
| <Toggle |
| v-model="form.totp_enabled" |
| :disabled="!form.totp_encryption_key_configured" |
| /> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.turnstile.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.turnstile.description') }} |
| </p> |
| </div> |
| <div class="space-y-5 p-6"> |
| |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.turnstile.enableTurnstile') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.turnstile.enableTurnstileHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.turnstile_enabled" /> |
| </div> |
| |
| |
| <div |
| v-if="form.turnstile_enabled" |
| class="border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div class="grid grid-cols-1 gap-6"> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.turnstile.siteKey') }} |
| </label> |
| <input |
| v-model="form.turnstile_site_key" |
| type="text" |
| class="input font-mono text-sm" |
| placeholder="0x4AAAAAAA..." |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.turnstile.siteKeyHint') }} |
| <a |
| href="https://dash.cloudflare.com/" |
| target="_blank" |
| class="text-primary-600 hover:text-primary-500" |
| >{{ t('admin.settings.turnstile.cloudflareDashboard') }}</a |
| > |
| </p> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.turnstile.secretKey') }} |
| </label> |
| <input |
| v-model="form.turnstile_secret_key" |
| type="password" |
| class="input font-mono text-sm" |
| placeholder="0x4AAAAAAA..." |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ |
| form.turnstile_secret_key_configured |
| ? t('admin.settings.turnstile.secretKeyConfiguredHint') |
| : t('admin.settings.turnstile.secretKeyHint') |
| }} |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.linuxdo.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.linuxdo.description') }} |
| </p> |
| </div> |
| <div class="space-y-5 p-6"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.linuxdo.enable') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.linuxdo.enableHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.linuxdo_connect_enabled" /> |
| </div> |
| |
| <div |
| v-if="form.linuxdo_connect_enabled" |
| class="border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div class="grid grid-cols-1 gap-6"> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.linuxdo.clientId') }} |
| </label> |
| <input |
| v-model="form.linuxdo_connect_client_id" |
| type="text" |
| class="input font-mono text-sm" |
| :placeholder="t('admin.settings.linuxdo.clientIdPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.linuxdo.clientIdHint') }} |
| </p> |
| </div> |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.linuxdo.clientSecret') }} |
| </label> |
| <input |
| v-model="form.linuxdo_connect_client_secret" |
| type="password" |
| class="input font-mono text-sm" |
| :placeholder=" |
| form.linuxdo_connect_client_secret_configured |
| ? t('admin.settings.linuxdo.clientSecretConfiguredPlaceholder') |
| : t('admin.settings.linuxdo.clientSecretPlaceholder') |
| " |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ |
| form.linuxdo_connect_client_secret_configured |
| ? t('admin.settings.linuxdo.clientSecretConfiguredHint') |
| : t('admin.settings.linuxdo.clientSecretHint') |
| }} |
| </p> |
| </div> |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.linuxdo.redirectUrl') }} |
| </label> |
| <input |
| v-model="form.linuxdo_connect_redirect_url" |
| type="url" |
| class="input font-mono text-sm" |
| :placeholder="t('admin.settings.linuxdo.redirectUrlPlaceholder')" |
| /> |
| <div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> |
| <button |
| type="button" |
| class="btn btn-secondary btn-sm w-fit" |
| @click="setAndCopyLinuxdoRedirectUrl" |
| > |
| {{ t('admin.settings.linuxdo.quickSetCopy') }} |
| </button> |
| <code |
| v-if="linuxdoRedirectUrlSuggestion" |
| class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300" |
| > |
| {{ linuxdoRedirectUrlSuggestion }} |
| </code> |
| </div> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.linuxdo.redirectUrlHint') }} |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-show="activeTab === 'users'" class="space-y-6"> |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.defaults.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.defaults.description') }} |
| </p> |
| </div> |
| <div class="space-y-6 p-6"> |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.defaults.defaultBalance') }} |
| </label> |
| <input |
| v-model.number="form.default_balance" |
| type="number" |
| step="0.01" |
| min="0" |
| class="input" |
| placeholder="0.00" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.defaults.defaultBalanceHint') }} |
| </p> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.defaults.defaultConcurrency') }} |
| </label> |
| <input |
| v-model.number="form.default_concurrency" |
| type="number" |
| min="1" |
| class="input" |
| placeholder="1" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.defaults.defaultConcurrencyHint') }} |
| </p> |
| </div> |
| </div> |
| |
| <div class="border-t border-gray-100 pt-4 dark:border-dark-700"> |
| <div class="mb-3 flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white"> |
| {{ t('admin.settings.defaults.defaultSubscriptions') }} |
| </label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.defaults.defaultSubscriptionsHint') }} |
| </p> |
| </div> |
| <button |
| type="button" |
| class="btn btn-secondary btn-sm" |
| @click="addDefaultSubscription" |
| :disabled="subscriptionGroups.length === 0" |
| > |
| {{ t('admin.settings.defaults.addDefaultSubscription') }} |
| </button> |
| </div> |
| |
| <div |
| v-if="form.default_subscriptions.length === 0" |
| class="rounded border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400" |
| > |
| {{ t('admin.settings.defaults.defaultSubscriptionsEmpty') }} |
| </div> |
| |
| <div v-else class="space-y-3"> |
| <div |
| v-for="(item, index) in form.default_subscriptions" |
| :key="`default-sub-${index}`" |
| class="grid grid-cols-1 gap-3 rounded border border-gray-200 p-3 md:grid-cols-[1fr_160px_auto] dark:border-dark-600" |
| > |
| <div> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.defaults.subscriptionGroup') }} |
| </label> |
| <Select |
| v-model="item.group_id" |
| class="default-sub-group-select" |
| :options="defaultSubscriptionGroupOptions" |
| :placeholder="t('admin.settings.defaults.subscriptionGroup')" |
| > |
| <template #selected="{ option }"> |
| <GroupBadge |
| v-if="option" |
| :name="(option as unknown as DefaultSubscriptionGroupOption).label" |
| :platform="(option as unknown as DefaultSubscriptionGroupOption).platform" |
| :subscription-type="(option as unknown as DefaultSubscriptionGroupOption).subscriptionType" |
| :rate-multiplier="(option as unknown as DefaultSubscriptionGroupOption).rate" |
| /> |
| <span v-else class="text-gray-400"> |
| {{ t('admin.settings.defaults.subscriptionGroup') }} |
| </span> |
| </template> |
| <template #option="{ option, selected }"> |
| <GroupOptionItem |
| :name="(option as unknown as DefaultSubscriptionGroupOption).label" |
| :platform="(option as unknown as DefaultSubscriptionGroupOption).platform" |
| :subscription-type="(option as unknown as DefaultSubscriptionGroupOption).subscriptionType" |
| :rate-multiplier="(option as unknown as DefaultSubscriptionGroupOption).rate" |
| :description="(option as unknown as DefaultSubscriptionGroupOption).description" |
| :selected="selected" |
| /> |
| </template> |
| </Select> |
| </div> |
| <div> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.defaults.subscriptionValidityDays') }} |
| </label> |
| <input |
| v-model.number="item.validity_days" |
| type="number" |
| min="1" |
| max="36500" |
| class="input h-[42px]" |
| /> |
| </div> |
| <div class="flex items-end"> |
| <button |
| type="button" |
| class="btn btn-secondary default-sub-delete-btn w-full text-red-600 hover:text-red-700 dark:text-red-400" |
| @click="removeDefaultSubscription(index)" |
| > |
| {{ t('common.delete') }} |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-show="activeTab === 'gateway'" class="space-y-6"> |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.claudeCode.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.claudeCode.description') }} |
| </p> |
| </div> |
| <div class="p-6"> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.claudeCode.minVersion') }} |
| </label> |
| <input |
| v-model="form.min_claude_code_version" |
| type="text" |
| class="input max-w-xs font-mono text-sm" |
| :placeholder="t('admin.settings.claudeCode.minVersionPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.claudeCode.minVersionHint') }} |
| </p> |
| </div> |
| <div class="mt-4"> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.claudeCode.maxVersion') }} |
| </label> |
| <input |
| v-model="form.max_claude_code_version" |
| type="text" |
| class="input max-w-xs font-mono text-sm" |
| :placeholder="t('admin.settings.claudeCode.maxVersionPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.claudeCode.maxVersionHint') }} |
| </p> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.scheduling.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.scheduling.description') }} |
| </p> |
| </div> |
| <div class="p-6"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.scheduling.allowUngroupedKey') }} |
| </label> |
| <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.scheduling.allowUngroupedKeyHint') }} |
| </p> |
| </div> |
| <label class="toggle"> |
| <input v-model="form.allow_ungrouped_key_scheduling" type="checkbox" /> |
| <span class="toggle-slider"></span> |
| </label> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-show="activeTab === 'general'" class="space-y-6"> |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.site.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.description') }} |
| </p> |
| </div> |
| <div class="space-y-6 p-6"> |
| |
| <div |
| class="flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20" |
| > |
| <div> |
| <h3 class="text-sm font-medium text-gray-900 dark:text-white"> |
| {{ t('admin.settings.site.backendMode') }} |
| </h3> |
| <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.backendModeDescription') }} |
| </p> |
| </div> |
| <Toggle v-model="form.backend_mode_enabled" /> |
| </div> |
| |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.site.siteName') }} |
| </label> |
| <input |
| v-model="form.site_name" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.site.siteNamePlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.siteNameHint') }} |
| </p> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.site.siteSubtitle') }} |
| </label> |
| <input |
| v-model="form.site_subtitle" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.site.siteSubtitlePlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.siteSubtitleHint') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.site.apiBaseUrl') }} |
| </label> |
| <input |
| v-model="form.api_base_url" |
| type="text" |
| class="input font-mono text-sm" |
| :placeholder="t('admin.settings.site.apiBaseUrlPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.apiBaseUrlHint') }} |
| </p> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.site.contactInfo') }} |
| </label> |
| <input |
| v-model="form.contact_info" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.site.contactInfoPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.contactInfoHint') }} |
| </p> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.site.docUrl') }} |
| </label> |
| <input |
| v-model="form.doc_url" |
| type="url" |
| class="input font-mono text-sm" |
| :placeholder="t('admin.settings.site.docUrlPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.docUrlHint') }} |
| </p> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.site.siteLogo') }} |
| </label> |
| <ImageUpload |
| v-model="form.site_logo" |
| mode="image" |
| :upload-label="t('admin.settings.site.uploadImage')" |
| :remove-label="t('admin.settings.site.remove')" |
| :hint="t('admin.settings.site.logoHint')" |
| :max-size="300 * 1024" |
| /> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.site.homeContent') }} |
| </label> |
| <textarea |
| v-model="form.home_content" |
| rows="6" |
| class="input font-mono text-sm" |
| :placeholder="t('admin.settings.site.homeContentPlaceholder')" |
| ></textarea> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.homeContentHint') }} |
| </p> |
| |
| <p class="mt-2 text-xs text-amber-600 dark:text-amber-400"> |
| {{ t('admin.settings.site.homeContentIframeWarning') }} |
| </p> |
| </div> |
| |
| |
| <div |
| class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.site.hideCcsImportButton') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.site.hideCcsImportButtonHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.hide_ccs_import_button" /> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.purchase.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.purchase.description') }} |
| </p> |
| </div> |
| <div class="space-y-6 p-6"> |
| |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.purchase.enabled') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.purchase.enabledHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.purchase_subscription_enabled" /> |
| </div> |
| |
| |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.purchase.url') }} |
| </label> |
| <input |
| v-model="form.purchase_subscription_url" |
| type="url" |
| class="input font-mono text-sm" |
| :placeholder="t('admin.settings.purchase.urlPlaceholder')" |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.purchase.urlHint') }} |
| </p> |
| <p class="mt-2 text-xs text-amber-600 dark:text-amber-400"> |
| {{ t('admin.settings.purchase.iframeWarning') }} |
| </p> |
| </div> |
| |
| |
| <div class="flex items-center gap-2 text-sm"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> |
| </svg> |
| <a |
| href="https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="text-blue-600 hover:underline dark:text-blue-400" |
| download="ADMIN_PAYMENT_INTEGRATION_API.md" |
| > |
| {{ t('admin.settings.purchase.integrationDoc') }} |
| </a> |
| <span class="text-gray-400 dark:text-gray-500">—</span> |
| <span class="text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.purchase.integrationDocHint') }} |
| </span> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.soraClient.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.soraClient.description') }} |
| </p> |
| </div> |
| <div class="space-y-6 p-6"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.soraClient.enabled') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.soraClient.enabledHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.sora_client_enabled" /> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.customMenu.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.customMenu.description') }} |
| </p> |
| </div> |
| <div class="space-y-4 p-6"> |
| |
| <div |
| v-for="(item, index) in form.custom_menu_items" |
| :key="item.id || index" |
| class="rounded-lg border border-gray-200 p-4 dark:border-dark-600" |
| > |
| <div class="mb-3 flex items-center justify-between"> |
| <span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.customMenu.itemLabel', { n: index + 1 }) }} |
| </span> |
| <div class="flex items-center gap-2"> |
| |
| <button |
| v-if="index > 0" |
| type="button" |
| class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700" |
| :title="t('admin.settings.customMenu.moveUp')" |
| @click="moveMenuItem(index, -1)" |
| > |
| <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" /></svg> |
| </button> |
| |
| <button |
| v-if="index < form.custom_menu_items.length - 1" |
| type="button" |
| class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700" |
| :title="t('admin.settings.customMenu.moveDown')" |
| @click="moveMenuItem(index, 1)" |
| > |
| <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg> |
| </button> |
| |
| <button |
| type="button" |
| class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20" |
| :title="t('admin.settings.customMenu.remove')" |
| @click="removeMenuItem(index)" |
| > |
| <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg> |
| </button> |
| </div> |
| </div> |
| |
| <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> |
| |
| <div> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.customMenu.name') }} |
| </label> |
| <input |
| v-model="item.label" |
| type="text" |
| class="input text-sm" |
| :placeholder="t('admin.settings.customMenu.namePlaceholder')" |
| /> |
| </div> |
| |
| |
| <div> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.customMenu.visibility') }} |
| </label> |
| <select v-model="item.visibility" class="input text-sm"> |
| <option value="user">{{ t('admin.settings.customMenu.visibilityUser') }}</option> |
| <option value="admin">{{ t('admin.settings.customMenu.visibilityAdmin') }}</option> |
| </select> |
| </div> |
| |
| |
| <div class="sm:col-span-2"> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.customMenu.url') }} |
| </label> |
| <input |
| v-model="item.url" |
| type="url" |
| class="input font-mono text-sm" |
| :placeholder="t('admin.settings.customMenu.urlPlaceholder')" |
| /> |
| </div> |
| |
| |
| <div class="sm:col-span-2"> |
| <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"> |
| {{ t('admin.settings.customMenu.iconSvg') }} |
| </label> |
| <ImageUpload |
| :model-value="item.icon_svg" |
| mode="svg" |
| size="sm" |
| :upload-label="t('admin.settings.customMenu.uploadSvg')" |
| :remove-label="t('admin.settings.customMenu.removeSvg')" |
| @update:model-value="(v: string) => item.icon_svg = v" |
| /> |
| </div> |
| </div> |
| </div> |
| |
| |
| <button |
| type="button" |
| class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 py-3 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400" |
| @click="addMenuItem" |
| > |
| <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg> |
| {{ t('admin.settings.customMenu.add') }} |
| </button> |
| </div> |
| </div> |
| |
| </div> |
| |
| |
| <div v-show="activeTab === 'email'" class="space-y-6"> |
| |
| <div v-if="!form.email_verify_enabled" class="card"> |
| <div class="p-6"> |
| <div class="flex items-start gap-3"> |
| <Icon name="mail" size="md" class="mt-0.5 flex-shrink-0 text-gray-400 dark:text-gray-500" /> |
| <div> |
| <h3 class="font-medium text-gray-900 dark:text-white"> |
| {{ t('admin.settings.emailTabDisabledTitle') }} |
| </h3> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.emailTabDisabledHint') }} |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="form.email_verify_enabled" class="card"> |
| <div |
| class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700" |
| > |
| <div> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.smtp.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.smtp.description') }} |
| </p> |
| </div> |
| <button |
| type="button" |
| @click="testSmtpConnection" |
| :disabled="testingSmtp" |
| class="btn btn-secondary btn-sm" |
| > |
| <svg v-if="testingSmtp" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ |
| testingSmtp |
| ? t('admin.settings.smtp.testing') |
| : t('admin.settings.smtp.testConnection') |
| }} |
| </button> |
| </div> |
| <div class="space-y-6 p-6"> |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.smtp.host') }} |
| </label> |
| <input |
| v-model="form.smtp_host" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.smtp.hostPlaceholder')" |
| /> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.smtp.port') }} |
| </label> |
| <input |
| v-model.number="form.smtp_port" |
| type="number" |
| min="1" |
| max="65535" |
| class="input" |
| :placeholder="t('admin.settings.smtp.portPlaceholder')" |
| /> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.smtp.username') }} |
| </label> |
| <input |
| v-model="form.smtp_username" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.smtp.usernamePlaceholder')" |
| /> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.smtp.password') }} |
| </label> |
| <input |
| v-model="form.smtp_password" |
| type="password" |
| class="input" |
| :placeholder=" |
| form.smtp_password_configured |
| ? t('admin.settings.smtp.passwordConfiguredPlaceholder') |
| : t('admin.settings.smtp.passwordPlaceholder') |
| " |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ |
| form.smtp_password_configured |
| ? t('admin.settings.smtp.passwordConfiguredHint') |
| : t('admin.settings.smtp.passwordHint') |
| }} |
| </p> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.smtp.fromEmail') }} |
| </label> |
| <input |
| v-model="form.smtp_from_email" |
| type="email" |
| class="input" |
| :placeholder="t('admin.settings.smtp.fromEmailPlaceholder')" |
| /> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.smtp.fromName') }} |
| </label> |
| <input |
| v-model="form.smtp_from_name" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.smtp.fromNamePlaceholder')" |
| /> |
| </div> |
| </div> |
| |
| |
| <div |
| class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700" |
| > |
| <div> |
| <label class="font-medium text-gray-900 dark:text-white">{{ |
| t('admin.settings.smtp.useTls') |
| }}</label> |
| <p class="text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.smtp.useTlsHint') }} |
| </p> |
| </div> |
| <Toggle v-model="form.smtp_use_tls" /> |
| </div> |
| |
| </div> |
| </div> |
| |
| <div v-if="form.email_verify_enabled" class="card"> |
| <div |
| class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700" |
| > |
| <div> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.mailjet.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.mailjet.description') }} |
| </p> |
| </div> |
| <button |
| type="button" |
| @click="testMailjetConnection" |
| :disabled="testingMailjet" |
| class="btn btn-secondary btn-sm" |
| > |
| <svg v-if="testingMailjet" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ |
| testingMailjet |
| ? t('admin.settings.mailjet.testing') |
| : t('admin.settings.mailjet.testConnection') |
| }} |
| </button> |
| </div> |
| <div class="space-y-6 p-6"> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.mailjet.provider') }} |
| </label> |
| <select v-model="form.email_provider" class="input"> |
| <option value="smtp">{{ t('admin.settings.mailjet.providerSmtp') }}</option> |
| <option value="mailjet">{{ t('admin.settings.mailjet.providerMailjet') }}</option> |
| </select> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.settings.mailjet.providerHint') }} |
| </p> |
| </div> |
| |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> |
| <div class="md:col-span-2"> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.mailjet.apiKey') }} |
| </label> |
| <input |
| v-model="form.mailjet_api_key" |
| type="password" |
| class="input" |
| :placeholder=" |
| form.mailjet_api_key_configured |
| ? t('admin.settings.mailjet.apiKeyConfiguredPlaceholder') |
| : t('admin.settings.mailjet.apiKeyPlaceholder') |
| " |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ |
| form.mailjet_api_key_configured |
| ? t('admin.settings.mailjet.apiKeyConfiguredHint') |
| : t('admin.settings.mailjet.apiKeyHint') |
| }} |
| </p> |
| </div> |
| <div class="md:col-span-2"> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.mailjet.secretKey') }} |
| </label> |
| <input |
| v-model="form.mailjet_secret_key" |
| type="password" |
| class="input" |
| :placeholder=" |
| form.mailjet_secret_key_configured |
| ? t('admin.settings.mailjet.secretKeyConfiguredPlaceholder') |
| : t('admin.settings.mailjet.secretKeyPlaceholder') |
| " |
| /> |
| <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| {{ |
| form.mailjet_secret_key_configured |
| ? t('admin.settings.mailjet.secretKeyConfiguredHint') |
| : t('admin.settings.mailjet.secretKeyHint') |
| }} |
| </p> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.mailjet.fromEmail') }} |
| </label> |
| <input |
| v-model="form.mailjet_from_email" |
| type="email" |
| class="input" |
| :placeholder="t('admin.settings.mailjet.fromEmailPlaceholder')" |
| /> |
| </div> |
| <div> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.mailjet.fromName') }} |
| </label> |
| <input |
| v-model="form.mailjet_from_name" |
| type="text" |
| class="input" |
| :placeholder="t('admin.settings.mailjet.fromNamePlaceholder')" |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="form.email_verify_enabled" class="card"> |
| <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> |
| <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> |
| {{ t('admin.settings.testEmail.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> |
| {{ |
| form.email_provider === 'mailjet' |
| ? t('admin.settings.testEmail.descriptionMailjet') |
| : t('admin.settings.testEmail.description') |
| }} |
| </p> |
| </div> |
| <div class="p-6"> |
| <div class="flex items-end gap-4"> |
| <div class="flex-1"> |
| <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {{ t('admin.settings.testEmail.recipientEmail') }} |
| </label> |
| <input |
| v-model="testEmailAddress" |
| type="email" |
| class="input" |
| :placeholder="t('admin.settings.testEmail.recipientEmailPlaceholder')" |
| /> |
| </div> |
| <button |
| type="button" |
| @click="sendTestEmail" |
| :disabled="sendingTestEmail || !testEmailAddress" |
| class="btn btn-secondary" |
| > |
| <svg |
| v-if="sendingTestEmail" |
| class="h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ |
| sendingTestEmail |
| ? t('admin.settings.testEmail.sending') |
| : t('admin.settings.testEmail.sendTestEmail') |
| }} |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-show="activeTab === 'backup'"> |
| <BackupSettings /> |
| </div> |
| |
| |
| <div v-show="activeTab === 'data'"> |
| <DataManagementSettings /> |
| </div> |
| |
| |
| <div v-show="activeTab !== 'backup' && activeTab !== 'data'" class="flex justify-end"> |
| <button type="submit" :disabled="saving" class="btn btn-primary"> |
| <svg v-if="saving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| {{ saving ? t('admin.settings.saving') : t('admin.settings.saveSettings') }} |
| </button> |
| </div> |
| </form> |
| </div> |
| </AppLayout> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, reactive, computed, onMounted } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { adminAPI } from '@/api' |
| import type { |
| SystemSettings, |
| UpdateSettingsRequest, |
| DefaultSubscriptionSetting |
| } from '@/api/admin/settings' |
| import type { AdminGroup } from '@/types' |
| import AppLayout from '@/components/layout/AppLayout.vue' |
| import Icon from '@/components/icons/Icon.vue' |
| import Select from '@/components/common/Select.vue' |
| import GroupBadge from '@/components/common/GroupBadge.vue' |
| import GroupOptionItem from '@/components/common/GroupOptionItem.vue' |
| import Toggle from '@/components/common/Toggle.vue' |
| import ImageUpload from '@/components/common/ImageUpload.vue' |
| import BackupSettings from '@/views/admin/BackupView.vue' |
| import DataManagementSettings from '@/views/admin/DataManagementView.vue' |
| import { useClipboard } from '@/composables/useClipboard' |
| import { useAppStore } from '@/stores' |
| import { useAdminSettingsStore } from '@/stores/adminSettings' |
| import { |
| isRegistrationEmailSuffixDomainValid, |
| normalizeRegistrationEmailSuffixDomain, |
| normalizeRegistrationEmailSuffixDomains, |
| parseRegistrationEmailSuffixWhitelistInput |
| } from '@/utils/registrationEmailPolicy' |
| |
| const { t } = useI18n() |
| const appStore = useAppStore() |
| const adminSettingsStore = useAdminSettingsStore() |
| |
| type SettingsTab = 'general' | 'security' | 'users' | 'gateway' | 'email' | 'backup' | 'data' |
| const activeTab = ref<SettingsTab>('general') |
| const settingsTabs = [ |
| { key: 'general' as SettingsTab, icon: 'home' as const }, |
| { key: 'security' as SettingsTab, icon: 'shield' as const }, |
| { key: 'users' as SettingsTab, icon: 'user' as const }, |
| { key: 'gateway' as SettingsTab, icon: 'server' as const }, |
| { key: 'email' as SettingsTab, icon: 'mail' as const }, |
| { key: 'backup' as SettingsTab, icon: 'database' as const }, |
| { key: 'data' as SettingsTab, icon: 'cube' as const }, |
| ] |
| const { copyToClipboard } = useClipboard() |
| |
| const loading = ref(true) |
| const saving = ref(false) |
| const testingSmtp = ref(false) |
| const testingMailjet = ref(false) |
| const sendingTestEmail = ref(false) |
| const testEmailAddress = ref('') |
| const registrationEmailSuffixWhitelistTags = ref<string[]>([]) |
| const registrationEmailSuffixWhitelistDraft = ref('') |
| |
| |
| const adminApiKeyLoading = ref(true) |
| const adminApiKeyExists = ref(false) |
| const adminApiKeyMasked = ref('') |
| const adminApiKeyOperating = ref(false) |
| const newAdminApiKey = ref('') |
| const subscriptionGroups = ref<AdminGroup[]>([]) |
| |
| |
| const overloadCooldownLoading = ref(true) |
| const overloadCooldownSaving = ref(false) |
| const overloadCooldownForm = reactive({ |
| enabled: true, |
| cooldown_minutes: 10 |
| }) |
| |
| |
| const streamTimeoutLoading = ref(true) |
| const streamTimeoutSaving = ref(false) |
| const streamTimeoutForm = reactive({ |
| enabled: true, |
| action: 'temp_unsched' as 'temp_unsched' | 'error' | 'none', |
| temp_unsched_minutes: 5, |
| threshold_count: 3, |
| threshold_window_minutes: 10 |
| }) |
| |
| |
| const rectifierLoading = ref(true) |
| const rectifierSaving = ref(false) |
| const rectifierForm = reactive({ |
| enabled: true, |
| thinking_signature_enabled: true, |
| thinking_budget_enabled: true |
| }) |
| |
| |
| const betaPolicyLoading = ref(true) |
| const betaPolicySaving = ref(false) |
| const betaPolicyForm = reactive({ |
| rules: [] as Array<{ |
| beta_token: string |
| action: 'pass' | 'filter' | 'block' |
| scope: 'all' | 'oauth' | 'apikey' | 'bedrock' |
| error_message?: string |
| }> |
| }) |
| |
| interface DefaultSubscriptionGroupOption { |
| value: number |
| label: string |
| description: string | null |
| platform: AdminGroup['platform'] |
| subscriptionType: AdminGroup['subscription_type'] |
| rate: number |
| [key: string]: unknown |
| } |
| |
| type SettingsForm = SystemSettings & { |
| smtp_password: string |
| mailjet_api_key: string |
| mailjet_secret_key: string |
| turnstile_secret_key: string |
| linuxdo_connect_client_secret: string |
| } |
| |
| const form = reactive<SettingsForm>({ |
| registration_enabled: true, |
| email_verify_enabled: false, |
| registration_email_suffix_whitelist: [], |
| promo_code_enabled: true, |
| invitation_code_enabled: false, |
| password_reset_enabled: false, |
| totp_enabled: false, |
| totp_encryption_key_configured: false, |
| default_balance: 0, |
| default_concurrency: 1, |
| default_subscriptions: [], |
| site_name: 'Sub2API', |
| site_logo: '', |
| site_subtitle: 'Subscription to API Conversion Platform', |
| api_base_url: '', |
| contact_info: '', |
| doc_url: '', |
| home_content: '', |
| backend_mode_enabled: false, |
| hide_ccs_import_button: false, |
| purchase_subscription_enabled: false, |
| purchase_subscription_url: '', |
| sora_client_enabled: false, |
| custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>, |
| frontend_url: '', |
| smtp_host: '', |
| smtp_port: 587, |
| smtp_username: '', |
| smtp_password: '', |
| smtp_password_configured: false, |
| smtp_from_email: '', |
| smtp_from_name: '', |
| smtp_use_tls: true, |
| email_provider: 'smtp', |
| mailjet_api_key: '', |
| mailjet_secret_key: '', |
| mailjet_api_key_configured: false, |
| mailjet_secret_key_configured: false, |
| mailjet_from_email: '', |
| mailjet_from_name: '', |
| |
| turnstile_enabled: false, |
| turnstile_site_key: '', |
| turnstile_secret_key: '', |
| turnstile_secret_key_configured: false, |
| |
| linuxdo_connect_enabled: false, |
| linuxdo_connect_client_id: '', |
| linuxdo_connect_client_secret: '', |
| linuxdo_connect_client_secret_configured: false, |
| linuxdo_connect_redirect_url: '', |
| |
| enable_model_fallback: false, |
| fallback_model_anthropic: 'claude-3-5-sonnet-20241022', |
| fallback_model_openai: 'gpt-4o', |
| fallback_model_gemini: 'gemini-2.5-pro', |
| fallback_model_antigravity: 'gemini-2.5-pro', |
| |
| enable_identity_patch: true, |
| identity_patch_prompt: '', |
| |
| ops_monitoring_enabled: true, |
| ops_realtime_monitoring_enabled: true, |
| ops_query_mode_default: 'auto', |
| ops_metrics_interval_seconds: 60, |
| |
| min_claude_code_version: '', |
| max_claude_code_version: '', |
| |
| allow_ungrouped_key_scheduling: false |
| }) |
| |
| const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() => |
| subscriptionGroups.value.map((group) => ({ |
| value: group.id, |
| label: group.name, |
| description: group.description, |
| platform: group.platform, |
| subscriptionType: group.subscription_type, |
| rate: group.rate_multiplier |
| })) |
| ) |
| |
| const registrationEmailSuffixWhitelistSeparatorKeys = new Set([' ', ',', ',', 'Enter', 'Tab']) |
| |
| function removeRegistrationEmailSuffixWhitelistTag(suffix: string) { |
| registrationEmailSuffixWhitelistTags.value = registrationEmailSuffixWhitelistTags.value.filter( |
| (item) => item !== suffix |
| ) |
| } |
| |
| function addRegistrationEmailSuffixWhitelistTag(raw: string) { |
| const suffix = normalizeRegistrationEmailSuffixDomain(raw) |
| if ( |
| !isRegistrationEmailSuffixDomainValid(suffix) || |
| registrationEmailSuffixWhitelistTags.value.includes(suffix) |
| ) { |
| return |
| } |
| registrationEmailSuffixWhitelistTags.value = [ |
| ...registrationEmailSuffixWhitelistTags.value, |
| suffix |
| ] |
| } |
| |
| function commitRegistrationEmailSuffixWhitelistDraft() { |
| if (!registrationEmailSuffixWhitelistDraft.value) { |
| return |
| } |
| addRegistrationEmailSuffixWhitelistTag(registrationEmailSuffixWhitelistDraft.value) |
| registrationEmailSuffixWhitelistDraft.value = '' |
| } |
| |
| function handleRegistrationEmailSuffixWhitelistDraftInput() { |
| registrationEmailSuffixWhitelistDraft.value = normalizeRegistrationEmailSuffixDomain( |
| registrationEmailSuffixWhitelistDraft.value |
| ) |
| } |
| |
| function handleRegistrationEmailSuffixWhitelistDraftKeydown(event: KeyboardEvent) { |
| if (event.isComposing) { |
| return |
| } |
| |
| if (registrationEmailSuffixWhitelistSeparatorKeys.has(event.key)) { |
| event.preventDefault() |
| commitRegistrationEmailSuffixWhitelistDraft() |
| return |
| } |
| |
| if ( |
| event.key === 'Backspace' && |
| !registrationEmailSuffixWhitelistDraft.value && |
| registrationEmailSuffixWhitelistTags.value.length > 0 |
| ) { |
| registrationEmailSuffixWhitelistTags.value.pop() |
| } |
| } |
| |
| function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) { |
| const text = event.clipboardData?.getData('text') || '' |
| if (!text.trim()) { |
| return |
| } |
| event.preventDefault() |
| const tokens = parseRegistrationEmailSuffixWhitelistInput(text) |
| for (const token of tokens) { |
| addRegistrationEmailSuffixWhitelistTag(token) |
| } |
| } |
| |
| |
| const linuxdoRedirectUrlSuggestion = computed(() => { |
| if (typeof window === 'undefined') return '' |
| const origin = |
| window.location.origin || `${window.location.protocol}//${window.location.host}` |
| return `${origin}/api/v1/auth/oauth/linuxdo/callback` |
| }) |
| |
| async function setAndCopyLinuxdoRedirectUrl() { |
| const url = linuxdoRedirectUrlSuggestion.value |
| if (!url) return |
| |
| form.linuxdo_connect_redirect_url = url |
| await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied')) |
| } |
| |
| |
| function addMenuItem() { |
| form.custom_menu_items.push({ |
| id: '', |
| label: '', |
| icon_svg: '', |
| url: '', |
| visibility: 'user', |
| sort_order: form.custom_menu_items.length, |
| }) |
| } |
| |
| function removeMenuItem(index: number) { |
| form.custom_menu_items.splice(index, 1) |
| |
| form.custom_menu_items.forEach((item, i) => { |
| item.sort_order = i |
| }) |
| } |
| |
| function moveMenuItem(index: number, direction: -1 | 1) { |
| const targetIndex = index + direction |
| if (targetIndex < 0 || targetIndex >= form.custom_menu_items.length) return |
| const items = form.custom_menu_items |
| const temp = items[index] |
| items[index] = items[targetIndex] |
| items[targetIndex] = temp |
| |
| items.forEach((item, i) => { |
| item.sort_order = i |
| }) |
| } |
| |
| async function loadSettings() { |
| loading.value = true |
| try { |
| const settings = await adminAPI.settings.getSettings() |
| Object.assign(form, settings) |
| form.backend_mode_enabled = settings.backend_mode_enabled |
| form.default_subscriptions = Array.isArray(settings.default_subscriptions) |
| ? settings.default_subscriptions |
| .filter((item) => item.group_id > 0 && item.validity_days > 0) |
| .map((item) => ({ |
| group_id: item.group_id, |
| validity_days: item.validity_days |
| })) |
| : [] |
| registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( |
| settings.registration_email_suffix_whitelist |
| ) |
| registrationEmailSuffixWhitelistDraft.value = '' |
| form.smtp_password = '' |
| form.mailjet_api_key = '' |
| form.mailjet_secret_key = '' |
| form.turnstile_secret_key = '' |
| form.linuxdo_connect_client_secret = '' |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| async function loadSubscriptionGroups() { |
| try { |
| const groups = await adminAPI.groups.getAll() |
| subscriptionGroups.value = groups.filter( |
| (group) => group.subscription_type === 'subscription' && group.status === 'active' |
| ) |
| } catch (error) { |
| console.error('Failed to load subscription groups:', error) |
| subscriptionGroups.value = [] |
| } |
| } |
| |
| function addDefaultSubscription() { |
| if (subscriptionGroups.value.length === 0) return |
| const existing = new Set(form.default_subscriptions.map((item) => item.group_id)) |
| const candidate = subscriptionGroups.value.find((group) => !existing.has(group.id)) |
| if (!candidate) return |
| form.default_subscriptions.push({ |
| group_id: candidate.id, |
| validity_days: 30 |
| }) |
| } |
| |
| function removeDefaultSubscription(index: number) { |
| form.default_subscriptions.splice(index, 1) |
| } |
| |
| async function saveSettings() { |
| saving.value = true |
| try { |
| const normalizedDefaultSubscriptions = form.default_subscriptions |
| .filter((item) => item.group_id > 0 && item.validity_days > 0) |
| .map((item: DefaultSubscriptionSetting) => ({ |
| group_id: item.group_id, |
| validity_days: Math.min(36500, Math.max(1, Math.floor(item.validity_days))) |
| })) |
| |
| const seenGroupIDs = new Set<number>() |
| const duplicateDefaultSubscription = normalizedDefaultSubscriptions.find((item) => { |
| if (seenGroupIDs.has(item.group_id)) { |
| return true |
| } |
| seenGroupIDs.add(item.group_id) |
| return false |
| }) |
| if (duplicateDefaultSubscription) { |
| appStore.showError( |
| t('admin.settings.defaults.defaultSubscriptionsDuplicate', { |
| groupId: duplicateDefaultSubscription.group_id |
| }) |
| ) |
| return |
| } |
| |
| const payload: UpdateSettingsRequest = { |
| registration_enabled: form.registration_enabled, |
| email_verify_enabled: form.email_verify_enabled, |
| registration_email_suffix_whitelist: registrationEmailSuffixWhitelistTags.value.map( |
| (suffix) => `@${suffix}` |
| ), |
| promo_code_enabled: form.promo_code_enabled, |
| invitation_code_enabled: form.invitation_code_enabled, |
| password_reset_enabled: form.password_reset_enabled, |
| totp_enabled: form.totp_enabled, |
| default_balance: form.default_balance, |
| default_concurrency: form.default_concurrency, |
| default_subscriptions: normalizedDefaultSubscriptions, |
| site_name: form.site_name, |
| site_logo: form.site_logo, |
| site_subtitle: form.site_subtitle, |
| api_base_url: form.api_base_url, |
| contact_info: form.contact_info, |
| doc_url: form.doc_url, |
| home_content: form.home_content, |
| backend_mode_enabled: form.backend_mode_enabled, |
| hide_ccs_import_button: form.hide_ccs_import_button, |
| purchase_subscription_enabled: form.purchase_subscription_enabled, |
| purchase_subscription_url: form.purchase_subscription_url, |
| sora_client_enabled: form.sora_client_enabled, |
| custom_menu_items: form.custom_menu_items, |
| frontend_url: form.frontend_url, |
| smtp_host: form.smtp_host, |
| smtp_port: form.smtp_port, |
| smtp_username: form.smtp_username, |
| smtp_password: form.smtp_password || undefined, |
| smtp_from_email: form.smtp_from_email, |
| smtp_from_name: form.smtp_from_name, |
| smtp_use_tls: form.smtp_use_tls, |
| email_provider: form.email_provider, |
| mailjet_api_key: form.mailjet_api_key || undefined, |
| mailjet_secret_key: form.mailjet_secret_key || undefined, |
| mailjet_from_email: form.mailjet_from_email, |
| mailjet_from_name: form.mailjet_from_name, |
| turnstile_enabled: form.turnstile_enabled, |
| turnstile_site_key: form.turnstile_site_key, |
| turnstile_secret_key: form.turnstile_secret_key || undefined, |
| linuxdo_connect_enabled: form.linuxdo_connect_enabled, |
| linuxdo_connect_client_id: form.linuxdo_connect_client_id, |
| linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined, |
| linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url, |
| enable_model_fallback: form.enable_model_fallback, |
| fallback_model_anthropic: form.fallback_model_anthropic, |
| fallback_model_openai: form.fallback_model_openai, |
| fallback_model_gemini: form.fallback_model_gemini, |
| fallback_model_antigravity: form.fallback_model_antigravity, |
| enable_identity_patch: form.enable_identity_patch, |
| identity_patch_prompt: form.identity_patch_prompt, |
| min_claude_code_version: form.min_claude_code_version, |
| max_claude_code_version: form.max_claude_code_version, |
| allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling |
| } |
| const updated = await adminAPI.settings.updateSettings(payload) |
| Object.assign(form, updated) |
| registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( |
| updated.registration_email_suffix_whitelist |
| ) |
| registrationEmailSuffixWhitelistDraft.value = '' |
| form.smtp_password = '' |
| form.mailjet_api_key = '' |
| form.mailjet_secret_key = '' |
| form.turnstile_secret_key = '' |
| form.linuxdo_connect_client_secret = '' |
| |
| await appStore.fetchPublicSettings(true) |
| await adminSettingsStore.fetch(true) |
| appStore.showSuccess(t('admin.settings.settingsSaved')) |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| saving.value = false |
| } |
| } |
| |
| async function testSmtpConnection() { |
| testingSmtp.value = true |
| try { |
| const result = await adminAPI.settings.testSmtpConnection({ |
| smtp_host: form.smtp_host, |
| smtp_port: form.smtp_port, |
| smtp_username: form.smtp_username, |
| smtp_password: form.smtp_password, |
| smtp_use_tls: form.smtp_use_tls |
| }) |
| |
| appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess')) |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| testingSmtp.value = false |
| } |
| } |
| |
| async function testMailjetConnection() { |
| testingMailjet.value = true |
| try { |
| const result = await adminAPI.settings.testMailjetConnection({ |
| mailjet_api_key: form.mailjet_api_key, |
| mailjet_secret_key: form.mailjet_secret_key |
| }) |
| appStore.showSuccess(result.message || t('admin.settings.mailjet.connectionSuccess')) |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.mailjet.connectionFailed') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| testingMailjet.value = false |
| } |
| } |
| |
| async function sendTestEmail() { |
| if (!testEmailAddress.value) { |
| appStore.showError(t('admin.settings.testEmail.enterRecipientHint')) |
| return |
| } |
| |
| sendingTestEmail.value = true |
| try { |
| const result = form.email_provider === 'mailjet' |
| ? await adminAPI.settings.sendMailjetTestEmail({ |
| email: testEmailAddress.value, |
| mailjet_api_key: form.mailjet_api_key, |
| mailjet_secret_key: form.mailjet_secret_key, |
| mailjet_from_email: form.mailjet_from_email, |
| mailjet_from_name: form.mailjet_from_name |
| }) |
| : await adminAPI.settings.sendTestEmail({ |
| email: testEmailAddress.value, |
| smtp_host: form.smtp_host, |
| smtp_port: form.smtp_port, |
| smtp_username: form.smtp_username, |
| smtp_password: form.smtp_password, |
| smtp_from_email: form.smtp_from_email, |
| smtp_from_name: form.smtp_from_name, |
| smtp_use_tls: form.smtp_use_tls |
| }) |
| |
| appStore.showSuccess( |
| result.message || |
| (form.email_provider === 'mailjet' |
| ? t('admin.settings.mailjet.testEmailSent') |
| : t('admin.settings.testEmailSent')) |
| ) |
| } catch (error: any) { |
| appStore.showError( |
| (form.email_provider === 'mailjet' |
| ? t('admin.settings.mailjet.testEmailFailed') |
| : t('admin.settings.failedToSendTestEmail')) + |
| ': ' + |
| (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| sendingTestEmail.value = false |
| } |
| } |
| |
| |
| async function loadAdminApiKey() { |
| adminApiKeyLoading.value = true |
| try { |
| const status = await adminAPI.settings.getAdminApiKey() |
| adminApiKeyExists.value = status.exists |
| adminApiKeyMasked.value = status.masked_key |
| } catch (error: any) { |
| console.error('Failed to load admin API key status:', error) |
| } finally { |
| adminApiKeyLoading.value = false |
| } |
| } |
| |
| async function createAdminApiKey() { |
| adminApiKeyOperating.value = true |
| try { |
| const result = await adminAPI.settings.regenerateAdminApiKey() |
| newAdminApiKey.value = result.key |
| adminApiKeyExists.value = true |
| adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4) |
| appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated')) |
| } catch (error: any) { |
| appStore.showError(error.message || t('common.error')) |
| } finally { |
| adminApiKeyOperating.value = false |
| } |
| } |
| |
| async function regenerateAdminApiKey() { |
| if (!confirm(t('admin.settings.adminApiKey.regenerateConfirm'))) return |
| await createAdminApiKey() |
| } |
| |
| async function deleteAdminApiKey() { |
| if (!confirm(t('admin.settings.adminApiKey.deleteConfirm'))) return |
| adminApiKeyOperating.value = true |
| try { |
| await adminAPI.settings.deleteAdminApiKey() |
| adminApiKeyExists.value = false |
| adminApiKeyMasked.value = '' |
| newAdminApiKey.value = '' |
| appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted')) |
| } catch (error: any) { |
| appStore.showError(error.message || t('common.error')) |
| } finally { |
| adminApiKeyOperating.value = false |
| } |
| } |
| |
| function copyNewKey() { |
| navigator.clipboard |
| .writeText(newAdminApiKey.value) |
| .then(() => { |
| appStore.showSuccess(t('admin.settings.adminApiKey.keyCopied')) |
| }) |
| .catch(() => { |
| appStore.showError(t('common.copyFailed')) |
| }) |
| } |
| |
| |
| async function loadOverloadCooldownSettings() { |
| overloadCooldownLoading.value = true |
| try { |
| const settings = await adminAPI.settings.getOverloadCooldownSettings() |
| Object.assign(overloadCooldownForm, settings) |
| } catch (error: any) { |
| console.error('Failed to load overload cooldown settings:', error) |
| } finally { |
| overloadCooldownLoading.value = false |
| } |
| } |
| |
| async function saveOverloadCooldownSettings() { |
| overloadCooldownSaving.value = true |
| try { |
| const updated = await adminAPI.settings.updateOverloadCooldownSettings({ |
| enabled: overloadCooldownForm.enabled, |
| cooldown_minutes: overloadCooldownForm.cooldown_minutes |
| }) |
| Object.assign(overloadCooldownForm, updated) |
| appStore.showSuccess(t('admin.settings.overloadCooldown.saved')) |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| overloadCooldownSaving.value = false |
| } |
| } |
| |
| |
| async function loadStreamTimeoutSettings() { |
| streamTimeoutLoading.value = true |
| try { |
| const settings = await adminAPI.settings.getStreamTimeoutSettings() |
| Object.assign(streamTimeoutForm, settings) |
| } catch (error: any) { |
| console.error('Failed to load stream timeout settings:', error) |
| } finally { |
| streamTimeoutLoading.value = false |
| } |
| } |
| |
| async function saveStreamTimeoutSettings() { |
| streamTimeoutSaving.value = true |
| try { |
| const updated = await adminAPI.settings.updateStreamTimeoutSettings({ |
| enabled: streamTimeoutForm.enabled, |
| action: streamTimeoutForm.action, |
| temp_unsched_minutes: streamTimeoutForm.temp_unsched_minutes, |
| threshold_count: streamTimeoutForm.threshold_count, |
| threshold_window_minutes: streamTimeoutForm.threshold_window_minutes |
| }) |
| Object.assign(streamTimeoutForm, updated) |
| appStore.showSuccess(t('admin.settings.streamTimeout.saved')) |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| streamTimeoutSaving.value = false |
| } |
| } |
| |
| |
| async function loadRectifierSettings() { |
| rectifierLoading.value = true |
| try { |
| const settings = await adminAPI.settings.getRectifierSettings() |
| Object.assign(rectifierForm, settings) |
| } catch (error: any) { |
| console.error('Failed to load rectifier settings:', error) |
| } finally { |
| rectifierLoading.value = false |
| } |
| } |
| |
| async function saveRectifierSettings() { |
| rectifierSaving.value = true |
| try { |
| const updated = await adminAPI.settings.updateRectifierSettings({ |
| enabled: rectifierForm.enabled, |
| thinking_signature_enabled: rectifierForm.thinking_signature_enabled, |
| thinking_budget_enabled: rectifierForm.thinking_budget_enabled |
| }) |
| Object.assign(rectifierForm, updated) |
| appStore.showSuccess(t('admin.settings.rectifier.saved')) |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.rectifier.saveFailed') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| rectifierSaving.value = false |
| } |
| } |
| |
| const betaPolicyActionOptions = computed(() => [ |
| { value: 'pass', label: t('admin.settings.betaPolicy.actionPass') }, |
| { value: 'filter', label: t('admin.settings.betaPolicy.actionFilter') }, |
| { value: 'block', label: t('admin.settings.betaPolicy.actionBlock') } |
| ]) |
| |
| const betaPolicyScopeOptions = computed(() => [ |
| { value: 'all', label: t('admin.settings.betaPolicy.scopeAll') }, |
| { value: 'oauth', label: t('admin.settings.betaPolicy.scopeOAuth') }, |
| { value: 'apikey', label: t('admin.settings.betaPolicy.scopeAPIKey') }, |
| { value: 'bedrock', label: t('admin.settings.betaPolicy.scopeBedrock') } |
| ]) |
| |
| |
| const betaDisplayNames: Record<string, string> = { |
| 'fast-mode-2026-02-01': 'Fast Mode', |
| 'context-1m-2025-08-07': 'Context 1M' |
| } |
| |
| function getBetaDisplayName(token: string): string { |
| return betaDisplayNames[token] || token |
| } |
| |
| async function loadBetaPolicySettings() { |
| betaPolicyLoading.value = true |
| try { |
| const settings = await adminAPI.settings.getBetaPolicySettings() |
| betaPolicyForm.rules = settings.rules |
| } catch (error: any) { |
| console.error('Failed to load beta policy settings:', error) |
| } finally { |
| betaPolicyLoading.value = false |
| } |
| } |
| |
| async function saveBetaPolicySettings() { |
| betaPolicySaving.value = true |
| try { |
| const updated = await adminAPI.settings.updateBetaPolicySettings({ |
| rules: betaPolicyForm.rules |
| }) |
| betaPolicyForm.rules = updated.rules |
| appStore.showSuccess(t('admin.settings.betaPolicy.saved')) |
| } catch (error: any) { |
| appStore.showError( |
| t('admin.settings.betaPolicy.saveFailed') + ': ' + (error.message || t('common.unknownError')) |
| ) |
| } finally { |
| betaPolicySaving.value = false |
| } |
| } |
| |
| onMounted(() => { |
| loadSettings() |
| loadSubscriptionGroups() |
| loadAdminApiKey() |
| loadOverloadCooldownSettings() |
| loadStreamTimeoutSettings() |
| loadRectifierSettings() |
| loadBetaPolicySettings() |
| }) |
| </script> |
| |
| <style scoped> |
| .default-sub-group-select :deep(.select-trigger) { |
| @apply h-[42px]; |
| } |
| |
| .default-sub-delete-btn { |
| @apply h-[42px]; |
| } |
| |
| |
| |
| |
| .settings-tabs-scroll { |
| scrollbar-width: thin; |
| scrollbar-color: transparent transparent; |
| } |
| .settings-tabs-scroll:hover { |
| scrollbar-color: rgb(0 0 0 / 0.15) transparent; |
| } |
| :root.dark .settings-tabs-scroll:hover { |
| scrollbar-color: rgb(255 255 255 / 0.2) transparent; |
| } |
| .settings-tabs-scroll::-webkit-scrollbar { |
| height: 3px; |
| } |
| .settings-tabs-scroll::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| .settings-tabs-scroll::-webkit-scrollbar-thumb { |
| background: transparent; |
| border-radius: 3px; |
| } |
| .settings-tabs-scroll:hover::-webkit-scrollbar-thumb { |
| background: rgb(0 0 0 / 0.15); |
| } |
| :root.dark .settings-tabs-scroll:hover::-webkit-scrollbar-thumb { |
| background: rgb(255 255 255 / 0.2); |
| } |
| |
| .settings-tabs { |
| @apply inline-flex min-w-full gap-0.5 rounded-2xl |
| border border-gray-100 bg-white/80 p-1 backdrop-blur-sm |
| dark:border-dark-700/50 dark:bg-dark-800/80; |
| box-shadow: 0 1px 3px rgb(0 0 0 / 0.04), 0 1px 2px rgb(0 0 0 / 0.02); |
| } |
| |
| @media (min-width: 640px) { |
| .settings-tabs { |
| @apply flex; |
| } |
| } |
| |
| .settings-tab { |
| @apply relative flex flex-1 items-center justify-center gap-1.5 |
| whitespace-nowrap rounded-xl px-2.5 py-2 |
| text-sm font-medium |
| text-gray-500 dark:text-dark-400 |
| transition-all duration-200 ease-out; |
| } |
| |
| .settings-tab:hover:not(.settings-tab-active) { |
| @apply text-gray-700 dark:text-gray-300; |
| background: rgb(0 0 0 / 0.03); |
| } |
| |
| :root.dark .settings-tab:hover:not(.settings-tab-active) { |
| background: rgb(255 255 255 / 0.04); |
| } |
| |
| .settings-tab-active { |
| @apply text-primary-600 dark:text-primary-400; |
| background: linear-gradient(135deg, rgba(20, 184, 166, 0.08), rgba(20, 184, 166, 0.03)); |
| box-shadow: 0 1px 2px rgba(20, 184, 166, 0.1); |
| } |
| |
| :root.dark .settings-tab-active { |
| background: linear-gradient(135deg, rgba(45, 212, 191, 0.12), rgba(45, 212, 191, 0.05)); |
| box-shadow: 0 1px 3px rgb(0 0 0 / 0.25); |
| } |
| |
| .settings-tab-icon { |
| @apply flex h-6 w-6 items-center justify-center rounded-lg |
| transition-all duration-200; |
| } |
| |
| .settings-tab-active .settings-tab-icon { |
| @apply bg-primary-500/15 text-primary-600 |
| dark:bg-primary-400/15 dark:text-primary-400; |
| } |
| </style> |
| |