sub2api/frontend/src/views/admin/SettingsView.vue

9508 lines
377 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<AppLayout>
<div class="mx-auto max-w-6xl space-y-6">
<!-- Loading State -->
<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>
<!-- Settings Form -->
<form v-else @submit.prevent="saveSettings" class="space-y-6" novalidate>
<!-- Tab Navigation -->
<div class="settings-tabs-shell">
<nav
class="settings-tabs-scroll"
role="tablist"
:aria-label="t('admin.settings.title')"
>
<div class="settings-tabs">
<button
v-for="tab in settingsTabs"
:key="tab.key"
:id="`settings-tab-${tab.key}`"
type="button"
role="tab"
:aria-selected="activeTab === tab.key"
:tabindex="activeTab === tab.key ? 0 : -1"
:class="[
'settings-tab',
activeTab === tab.key && 'settings-tab-active',
]"
@click="selectSettingsTab(tab.key)"
@keydown="handleSettingsTabKeydown($event, tab.key)"
>
<span class="settings-tab-icon">
<Icon :name="tab.icon" size="sm" />
</span>
<span class="settings-tab-label">{{
t(`admin.settings.tabs.${tab.key}`)
}}</span>
</button>
</div>
</nav>
</div>
<!-- Tab: Security Admin API Key -->
<div v-show="activeTab === 'security'" class="space-y-6">
<!-- Admin API Key Settings -->
<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">
<!-- Security Warning -->
<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>
<!-- Loading State -->
<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>
<!-- No Key Configured -->
<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>
<!-- Key Exists -->
<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>
<!-- Newly Generated Key Display -->
<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>
<!-- /Tab: Security Admin API Key -->
<!-- Tab: Gateway -->
<div v-show="activeTab === 'gateway'" class="space-y-6">
<!-- Overload Cooldown (529) Settings -->
<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>
<!-- Rate Limit Cooldown (429) Settings -->
<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.rateLimit429Cooldown.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.rateLimit429Cooldown.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<div
v-if="rateLimit429CooldownLoading"
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.rateLimit429Cooldown.enabled")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.rateLimit429Cooldown.enabledHint") }}
</p>
</div>
<Toggle v-model="rateLimit429CooldownForm.enabled" />
</div>
<div
v-if="rateLimit429CooldownForm.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.rateLimit429Cooldown.cooldownSeconds",
)
}}
</label>
<input
v-model.number="rateLimit429CooldownForm.cooldown_seconds"
type="number"
min="1"
max="7200"
class="input w-32"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.rateLimit429Cooldown.cooldownSecondsHint",
)
}}
</p>
</div>
</div>
<div
class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"
>
<button
type="button"
@click="saveRateLimit429CooldownSettings"
:disabled="rateLimit429CooldownSaving"
class="btn btn-primary btn-sm"
>
<svg
v-if="rateLimit429CooldownSaving"
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>
{{
rateLimit429CooldownSaving
? t("common.saving")
: t("common.save")
}}
</button>
</div>
</template>
</div>
</div>
<!-- Stream Timeout Settings -->
<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">
<!-- Loading State -->
<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>
<!-- Enable Stream Timeout -->
<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>
<!-- Settings - Only show when enabled -->
<div
v-if="streamTimeoutForm.enabled"
class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<!-- Action -->
<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>
<!-- Temp Unsched Minutes (only show when action is temp_unsched) -->
<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>
<!-- Threshold Count -->
<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>
<!-- Threshold Window Minutes -->
<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>
<!-- Save Button -->
<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>
<!-- Request Rectifier Settings -->
<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">
<!-- Loading State -->
<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>
<!-- Master Toggle -->
<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>
<!-- Sub-toggles (only show when master is enabled) -->
<div
v-if="rectifierForm.enabled"
class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<!-- Thinking Signature Rectifier -->
<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>
<!-- Thinking Budget Rectifier -->
<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>
<!-- API Key Signature Rectifier -->
<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.apikeySignature")
}}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.rectifier.apikeySignatureHint") }}
</p>
</div>
<Toggle v-model="rectifierForm.apikey_signature_enabled" />
</div>
<!-- Custom Patterns (only when apikey_signature_enabled) -->
<div
v-if="rectifierForm.apikey_signature_enabled"
class="ml-4 space-y-3 border-l-2 border-gray-200 pl-4 dark:border-dark-600"
>
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>{{
t("admin.settings.rectifier.apikeyPatterns")
}}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.rectifier.apikeyPatternsHint") }}
</p>
</div>
<div
v-for="(
_, index
) in rectifierForm.apikey_signature_patterns"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="rectifierForm.apikey_signature_patterns[index]"
type="text"
class="input input-sm flex-1"
:placeholder="
t('admin.settings.rectifier.apikeyPatternPlaceholder')
"
/>
<button
type="button"
@click="
rectifierForm.apikey_signature_patterns.splice(
index,
1,
)
"
class="btn btn-ghost btn-xs text-red-500 hover:text-red-700"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<button
type="button"
@click="rectifierForm.apikey_signature_patterns.push('')"
class="btn btn-ghost btn-xs text-primary-600 dark:text-primary-400"
>
+ {{ t("admin.settings.rectifier.addPattern") }}
</button>
</div>
</div>
<!-- Save Button -->
<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>
<!-- Beta Policy Settings -->
<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">
<!-- Loading State -->
<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>
<!-- Rule Cards -->
<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">
<!-- Action -->
<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>
<!-- Scope -->
<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>
<!-- Error Message (only when action=block) -->
<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>
<!-- Quick Presets (only for tokens with presets) -->
<div v-if="betaPresets[rule.beta_token]?.length" class="mt-3">
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.betaPolicy.quickPresets") }}
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in betaPresets[rule.beta_token]"
:key="preset.label"
type="button"
class="inline-flex items-center gap-1 rounded-md border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 transition-colors hover:bg-primary-100 dark:border-primary-800 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/50"
@click="applyBetaPreset(rule, preset)"
:title="preset.description"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- Model Whitelist -->
<div class="mt-3">
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.betaPolicy.modelWhitelist") }}
</label>
<p class="mb-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.betaPolicy.modelWhitelistHint") }}
</p>
<!-- Existing patterns -->
<div
v-for="(_, index) in rule.model_whitelist || []"
:key="index"
class="mb-1.5 flex items-center gap-2"
>
<input
v-model="rule.model_whitelist![index]"
type="text"
class="input input-sm flex-1"
:placeholder="
t('admin.settings.betaPolicy.modelPatternPlaceholder')
"
/>
<button
type="button"
@click="rule.model_whitelist!.splice(index, 1)"
class="shrink-0 rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Add pattern button -->
<button
type="button"
@click="
if (!rule.model_whitelist) rule.model_whitelist = [];
rule.model_whitelist.push('');
"
class="mb-2 inline-flex items-center gap-1 text-xs text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<svg
class="h-3.5 w-3.5"
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.betaPolicy.addModelPattern") }}
</button>
<!-- Common pattern chips -->
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-gray-400 dark:text-gray-500"
>{{
t("admin.settings.betaPolicy.commonPatterns")
}}:</span
>
<button
v-for="pattern in commonModelPatterns"
:key="pattern"
type="button"
class="rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-600 transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-700 dark:hover:bg-primary-900/30 dark:hover:text-primary-300"
@click="addQuickPattern(rule, pattern)"
>
{{ pattern }}
</button>
</div>
</div>
<!-- Fallback Action (only when model_whitelist is non-empty) -->
<div
v-if="
rule.model_whitelist && rule.model_whitelist.length > 0
"
class="mt-3"
>
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.betaPolicy.fallbackAction") }}
</label>
<Select
:modelValue="rule.fallback_action || 'pass'"
@update:modelValue="rule.fallback_action = $event as any"
:options="betaPolicyActionOptions"
/>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.betaPolicy.fallbackActionHint") }}
</p>
<!-- Fallback Error Message (only when fallback_action=block) -->
<div v-if="rule.fallback_action === 'block'" class="mt-2">
<input
v-model="rule.fallback_error_message"
type="text"
class="input"
:placeholder="
t(
'admin.settings.betaPolicy.fallbackErrorMessagePlaceholder',
)
"
/>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.betaPolicy.errorMessageHint") }}
</p>
</div>
</div>
</div>
<!-- Save Button -->
<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>
<!-- OpenAI Fast/Flex Policy Settings -->
<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.openaiFastPolicy.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.openaiFastPolicy.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<!-- Empty state -->
<div
v-if="openaiFastPolicyForm.rules.length === 0"
class="rounded-lg border border-dashed border-gray-200 p-6 text-center text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.empty") }}
</div>
<!-- Rule Cards -->
<div
v-for="(rule, ruleIndex) in openaiFastPolicyForm.rules"
:key="ruleIndex"
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-900 dark:text-white"
>
{{
t("admin.settings.openaiFastPolicy.ruleHeader", {
index: ruleIndex + 1,
})
}}
</span>
<button
type="button"
@click="removeOpenAIFastPolicyRule(ruleIndex)"
class="rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
:title="t('admin.settings.openaiFastPolicy.removeRule')"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<!-- Service Tier -->
<div>
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.serviceTier") }}
</label>
<Select
:modelValue="rule.service_tier"
@update:modelValue="
rule.service_tier = $event as
| 'all'
| 'priority'
| 'flex'
"
:options="openaiFastPolicyTierOptions"
/>
</div>
<!-- Action -->
<div>
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.action") }}
</label>
<Select
:modelValue="rule.action"
@update:modelValue="
rule.action = $event as 'pass' | 'filter' | 'block'
"
:options="openaiFastPolicyActionOptions"
/>
</div>
<!-- Scope -->
<div>
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.scope") }}
</label>
<Select
:modelValue="rule.scope"
@update:modelValue="
rule.scope = $event as
| 'all'
| 'oauth'
| 'apikey'
| 'bedrock'
"
:options="openaiFastPolicyScopeOptions"
/>
</div>
</div>
<!-- Error Message (only when action=block) -->
<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.openaiFastPolicy.errorMessage") }}
</label>
<input
v-model="rule.error_message"
type="text"
class="input"
:placeholder="
t(
'admin.settings.openaiFastPolicy.errorMessagePlaceholder',
)
"
/>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.openaiFastPolicy.errorMessageHint") }}
</p>
</div>
<!-- Model Whitelist -->
<div class="mt-3">
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.modelWhitelist") }}
</label>
<p class="mb-2 text-xs text-gray-400 dark:text-gray-500">
{{
t("admin.settings.openaiFastPolicy.modelWhitelistHint")
}}
</p>
<div
v-for="(_, patternIdx) in rule.model_whitelist || []"
:key="patternIdx"
class="mb-1.5 flex items-center gap-2"
>
<input
v-model="rule.model_whitelist![patternIdx]"
type="text"
class="input input-sm flex-1"
:placeholder="
t(
'admin.settings.openaiFastPolicy.modelPatternPlaceholder',
)
"
/>
<button
type="button"
@click="
removeOpenAIFastPolicyModelPattern(rule, patternIdx)
"
class="shrink-0 rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<button
type="button"
@click="addOpenAIFastPolicyModelPattern(rule)"
class="mb-2 inline-flex items-center gap-1 text-xs text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<svg
class="h-3.5 w-3.5"
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.openaiFastPolicy.addModelPattern") }}
</button>
</div>
<!-- Fallback Action (only when model_whitelist is non-empty) -->
<div
v-if="
rule.model_whitelist && rule.model_whitelist.length > 0
"
class="mt-3"
>
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.fallbackAction") }}
</label>
<Select
:modelValue="rule.fallback_action || 'pass'"
@update:modelValue="
rule.fallback_action = $event as
| 'pass'
| 'filter'
| 'block'
"
:options="openaiFastPolicyActionOptions"
/>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{{
t("admin.settings.openaiFastPolicy.fallbackActionHint")
}}
</p>
<div v-if="rule.fallback_action === 'block'" class="mt-2">
<input
v-model="rule.fallback_error_message"
type="text"
class="input"
:placeholder="
t(
'admin.settings.openaiFastPolicy.fallbackErrorMessagePlaceholder',
)
"
/>
</div>
</div>
</div>
<!-- Add Rule Button -->
<div>
<button
type="button"
@click="addOpenAIFastPolicyRule"
class="btn btn-secondary btn-sm inline-flex items-center gap-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="M12 4v16m8-8H4"
/>
</svg>
{{ t("admin.settings.openaiFastPolicy.addRule") }}
</button>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.openaiFastPolicy.saveHint") }}
</p>
</div>
</div>
</div>
</div>
<!-- /Tab: Gateway -->
<!-- Tab: Security — Registration, Turnstile, LinuxDo -->
<div v-show="activeTab === 'security'" class="space-y-6">
<!-- Registration Settings -->
<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">
<!-- Enable Registration -->
<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>
<!-- Email Verification -->
<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>
<!-- Email Suffix Whitelist -->
<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>
<!-- Promo Code -->
<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>
<!-- Invitation Code -->
<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>
<!-- Password Reset - Only show when email verification is enabled -->
<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>
<!-- Frontend URL - Only show when password reset is enabled -->
<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>
<!-- TOTP 2FA -->
<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>
<!-- Warning when encryption key not configured -->
<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>
<!-- Cloudflare Turnstile Settings -->
<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">
<!-- Enable Turnstile -->
<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>
<!-- Turnstile Keys - Only show when enabled -->
<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>
<!-- LinuxDo Connect OAuth 登录 -->
<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>
<!-- GitHub / Google 邮箱快捷登录 -->
<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">
{{ localText("邮箱快捷登录", "Email OAuth Sign-in") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"开启 GitHub 或 Google 邮箱授权登录后,系统会读取已验证邮箱,存在则直接登录,不存在则自动注册。",
"After GitHub or Google email OAuth is enabled, the system reads a verified email, signs in matching users, and auto-registers missing users.",
)
}}
</p>
</div>
<div class="space-y-6 p-6">
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
GitHub
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"GitHub OAuth App 需要 read:user user:email 权限,回调地址填写下方后端地址。",
"GitHub OAuth App needs read:user user:email scopes. Use the backend callback URL below.",
)
}}
</p>
</div>
<Toggle v-model="form.github_oauth_enabled" />
</div>
<div v-if="form.github_oauth_enabled" class="mt-4 space-y-4">
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
<template v-if="isZhLocale">
开通引导GitHub Settings → Developer settings →
<a
data-testid="github-oauth-apps-guide-link"
href="https://github.com/settings/developers"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>OAuth Apps</a>
→ New OAuth AppHomepage URL 填站点域名Authorization callback URL 填下面的后端回调地址。
</template>
<template v-else>
Setup guide: GitHub Settings → Developer settings →
<a
data-testid="github-oauth-apps-guide-link"
href="https://github.com/settings/developers"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>OAuth Apps</a>
→ New OAuth App. Use your site origin as Homepage URL and the backend callback URL below as Authorization callback URL.
</template>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input
v-model="form.github_oauth_client_id"
type="text"
class="input font-mono text-sm"
placeholder="GitHub OAuth Client ID"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input
v-model="form.github_oauth_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.github_oauth_client_secret_configured
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
: 'GitHub OAuth Client Secret'
"
/>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("后端回调地址", "Backend Callback URL") }}
</label>
<input
v-model="form.github_oauth_redirect_url"
type="url"
class="input font-mono text-sm"
placeholder="https://your-domain.com/api/v1/auth/oauth/github/callback"
/>
<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="setAndCopyEmailOAuthRedirectUrl('github')"
>
{{ localText("生成并复制", "Generate and copy") }}
</button>
<code
v-if="githubOAuthRedirectUrlSuggestion"
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"
>
{{ githubOAuthRedirectUrlSuggestion }}
</code>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("前端回跳地址", "Frontend Callback URL") }}
</label>
<input
v-model="form.github_oauth_frontend_redirect_url"
type="text"
class="input font-mono text-sm"
placeholder="/auth/oauth/callback"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
Google
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"Google OAuth 客户端需要 openid email profile 范围,并在凭据里登记后端回调地址。",
"Google OAuth client needs openid email profile scopes and the backend callback URL registered in credentials.",
)
}}
</p>
</div>
<Toggle v-model="form.google_oauth_enabled" />
</div>
<div v-if="form.google_oauth_enabled" class="mt-4 space-y-4">
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
{{
localText(
"开通引导Google Cloud Console → APIs & Services → OAuth consent screen 完成同意屏幕Credentials → Create Credentials → OAuth client ID类型选择 Web application并把下面地址加入 Authorized redirect URIs。",
"Setup guide: Google Cloud Console → APIs & Services → OAuth consent screen, then Credentials → Create Credentials → OAuth client ID, choose Web application, and add the URL below to Authorized redirect URIs.",
)
}}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input
v-model="form.google_oauth_client_id"
type="text"
class="input font-mono text-sm"
placeholder="Google OAuth Client ID"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input
v-model="form.google_oauth_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.google_oauth_client_secret_configured
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
: 'Google OAuth Client Secret'
"
/>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("后端回调地址", "Backend Callback URL") }}
</label>
<input
v-model="form.google_oauth_redirect_url"
type="url"
class="input font-mono text-sm"
placeholder="https://your-domain.com/api/v1/auth/oauth/google/callback"
/>
<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="setAndCopyEmailOAuthRedirectUrl('google')"
>
{{ localText("生成并复制", "Generate and copy") }}
</button>
<code
v-if="googleOAuthRedirectUrlSuggestion"
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"
>
{{ googleOAuthRedirectUrlSuggestion }}
</code>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("前端回跳地址", "Frontend Callback URL") }}
</label>
<input
v-model="form.google_oauth_frontend_redirect_url"
type="text"
class="input font-mono text-sm"
placeholder="/auth/oauth/callback"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- WeChat Connect OAuth 登录 -->
<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.wechatConnect.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.wechatConnect.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.wechatConnect.enabledLabel")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.wechatConnect.enabledHint") }}
</p>
</div>
<Toggle
v-model="form.wechat_connect_enabled"
data-testid="wechat-connect-enabled"
/>
</div>
<div
v-if="form.wechat_connect_enabled"
class="space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div class="space-y-4">
<div
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
>
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
{{ localText("PC 应用", "PC App") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"桌面浏览器通过微信开放平台扫码登录。可与公众号或移动应用同时存在。",
"Desktop browsers sign in through WeChat Open Platform QR login. This can coexist with Official Account or Mobile App.",
)
}}
</p>
</div>
<Toggle
:model-value="form.wechat_connect_open_enabled"
data-testid="wechat-connect-open-enabled"
@update:model-value="handleWeChatOpenEnabledChange"
/>
</div>
<div
v-if="form.wechat_connect_open_enabled"
class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"
>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ localText("PC AppID", "PC App ID") }}
</label>
<input
v-model="form.wechat_connect_open_app_id"
data-testid="wechat-connect-open-app-id"
type="text"
class="input font-mono text-sm"
:placeholder="
localText(
'微信开放平台 PC 应用 AppID',
'WeChat Open Platform PC App ID',
)
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ localText("PC AppSecret", "PC App Secret") }}
</label>
<input
v-model="form.wechat_connect_open_app_secret"
data-testid="wechat-connect-open-app-secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.wechat_connect_open_app_secret_configured
? localText(
'密钥已配置,留空以保留当前值。',
'Secret configured. Leave empty to keep the current value.',
)
: localText(
'微信开放平台 PC 应用 AppSecret',
'WeChat Open Platform PC App Secret',
)
"
/>
</div>
</div>
</div>
<div
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
>
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
{{ localText("公众号", "Official Account") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"仅在微信内浏览器可用;非微信环境下会显示不可用。",
"Only available inside the WeChat browser. It is shown as unavailable outside WeChat.",
)
}}
</p>
</div>
<Toggle
:model-value="form.wechat_connect_mp_enabled"
data-testid="wechat-connect-mp-enabled"
@update:model-value="handleWeChatMPEnabledChange"
/>
</div>
<div
v-if="form.wechat_connect_mp_enabled"
class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"
>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ localText("公众号 AppID", "Official Account App ID") }}
</label>
<input
v-model="form.wechat_connect_mp_app_id"
data-testid="wechat-connect-mp-app-id"
type="text"
class="input font-mono text-sm"
:placeholder="
localText(
'公众号 AppID',
'Official Account App ID',
)
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
localText(
"公众号 AppSecret",
"Official Account App Secret",
)
}}
</label>
<input
v-model="form.wechat_connect_mp_app_secret"
data-testid="wechat-connect-mp-app-secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.wechat_connect_mp_app_secret_configured
? localText(
'密钥已配置,留空以保留当前值。',
'Secret configured. Leave empty to keep the current value.',
)
: localText(
'公众号 AppSecret',
'Official Account App Secret',
)
"
/>
</div>
</div>
</div>
<div
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
>
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
{{ localText("移动应用", "Mobile App") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"原生移动端通过微信 SDK 唤起授权,网页端不会直接发起该流程。",
"Native mobile clients start authorization through the WeChat SDK. The web UI does not launch this flow directly.",
)
}}
</p>
</div>
<Toggle
:model-value="form.wechat_connect_mobile_enabled"
data-testid="wechat-connect-mobile-enabled"
@update:model-value="handleWeChatMobileEnabledChange"
/>
</div>
<div
v-if="form.wechat_connect_mobile_enabled"
class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"
>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ localText("移动应用 AppID", "Mobile App ID") }}
</label>
<input
v-model="form.wechat_connect_mobile_app_id"
data-testid="wechat-connect-mobile-app-id"
type="text"
class="input font-mono text-sm"
:placeholder="
localText(
'移动应用 AppID',
'Mobile App ID',
)
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ localText("移动应用 AppSecret", "Mobile App Secret") }}
</label>
<input
v-model="form.wechat_connect_mobile_app_secret"
data-testid="wechat-connect-mobile-app-secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.wechat_connect_mobile_app_secret_configured
? localText(
'密钥已配置,留空以保留当前值。',
'Secret configured. Leave empty to keep the current value.',
)
: localText(
'移动应用 AppSecret',
'Mobile App Secret',
)
"
/>
</div>
</div>
</div>
</div>
<div
v-if="
form.wechat_connect_open_enabled &&
(form.wechat_connect_mp_enabled ||
form.wechat_connect_mobile_enabled)
"
class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/10 dark:text-amber-300"
>
{{
localText(
"如果同时启用 PC 应用和公众号/移动应用,这些应用需要挂在同一个微信开放平台主体下,否则 UnionID 无法稳定归并账号。",
"When PC App is enabled together with Official Account or Mobile App, they should belong to the same WeChat Open Platform account so UnionID can merge identities reliably.",
)
}}
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
localText(
"浏览器回调地址",
"Browser Redirect URL",
)
}}
</label>
<input
data-testid="wechat-connect-redirect-url"
v-model="form.wechat_connect_redirect_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.wechatConnect.redirectUrlPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
localText(
"用于 PC 应用和公众号的网页回调。移动应用走原生 SDK 时不直接使用这个浏览器回调。",
"Used by PC App and Official Account browser callbacks. Native mobile SDK flows do not start from this browser callback directly.",
)
}}
</p>
<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="setAndCopyWeChatRedirectUrl"
>
{{ t("admin.settings.wechatConnect.generateAndCopy") }}
</button>
<code
v-if="wechatRedirectUrlSuggestion"
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"
>
{{ wechatRedirectUrlSuggestion }}
</code>
</div>
</div>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.wechatConnect.frontendRedirectUrlLabel") }}
</label>
<input
data-testid="wechat-connect-frontend-redirect-url"
v-model="form.wechat_connect_frontend_redirect_url"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.wechatConnect.frontendRedirectUrlPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.wechatConnect.frontendRedirectUrlHint") }}
</p>
</div>
</div>
</div>
</div>
<!-- DingTalk Connect OAuth 登录 -->
<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.dingtalk.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.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.dingtalk.enable")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.enableHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_enabled" />
</div>
<div
v-if="form.dingtalk_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.dingtalk.clientId") }}
</label>
<input
v-model="form.dingtalk_connect_client_id"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.dingtalk.clientIdPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.clientIdHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.clientSecret") }}
</label>
<input
v-model="form.dingtalk_connect_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.dingtalk_connect_client_secret_configured
? t(
'admin.settings.dingtalk.clientSecretConfiguredPlaceholder',
)
: t('admin.settings.dingtalk.clientSecretPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.dingtalk_connect_client_secret_configured
? t(
"admin.settings.dingtalk.clientSecretConfiguredHint",
)
: t("admin.settings.dingtalk.clientSecretHint")
}}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.dingtalk.redirectUrl") }}
</label>
<input
v-model="form.dingtalk_connect_redirect_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.dingtalk.redirectUrlPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.redirectUrlHint") }}
</p>
</div>
<!-- Corp Restriction Policy -->
<div 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.dingtalk.corpPolicy.label") }}
</label>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.corpPolicy.hint") }}
</p>
<div class="space-y-2">
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="form.dingtalk_connect_corp_restriction_policy"
type="radio"
value="none"
class="h-4 w-4 text-primary-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.none") }}
</span>
</label>
<label class="flex cursor-pointer items-center gap-3">
<input
v-model="form.dingtalk_connect_corp_restriction_policy"
type="radio"
value="internal_only"
class="h-4 w-4 text-primary-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t("admin.settings.dingtalk.corpPolicy.internalOnly") }}
</span>
</label>
</div>
</div>
<!-- bypass_registration toggle仅 internal_only 模式下可见可用) -->
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.bypassRegistration")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.bypassRegistrationHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_bypass_registration" />
</div>
<!-- 身份同步开关(仅 internal_only 模式下可见) -->
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncDisplayName")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncDisplayNameHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_display_name" />
</div>
<div v-if="form.dingtalk_connect_sync_display_name" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncDisplayNameTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_display_name_attr_key"
type="text"
placeholder="dingtalk_name"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_display_name_attr_name"
type="text"
placeholder="钉钉姓名"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_display_name" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncDisplayNameTargetHint") }}
</p>
</div>
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncCorpEmail")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncCorpEmailHint") }}
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
{{ t("admin.settings.dingtalk.syncCorpEmailPermissionHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_corp_email" />
</div>
<div v-if="form.dingtalk_connect_sync_corp_email" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncCorpEmailTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_corp_email_attr_key"
type="text"
placeholder="dingtalk_email"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_corp_email_attr_name"
type="text"
placeholder="钉钉企业邮箱"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_corp_email" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncCorpEmailTargetHint") }}
</p>
</div>
<div
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.dingtalk.syncDept")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.dingtalk.syncDeptHint") }}
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
{{ t("admin.settings.dingtalk.syncDeptPermissionHint") }}
</p>
</div>
<Toggle v-model="form.dingtalk_connect_sync_dept" />
</div>
<div v-if="form.dingtalk_connect_sync_dept" class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncDeptTarget") }}
</label>
<input
v-model="form.dingtalk_connect_sync_dept_attr_key"
type="text"
placeholder="dingtalk_department"
class="input text-sm flex-1 max-w-xs"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
</label>
<input
v-model="form.dingtalk_connect_sync_dept_attr_name"
type="text"
placeholder="钉钉部门"
class="input text-sm flex-1 max-w-xs"
/>
</div>
</div>
<p v-if="form.dingtalk_connect_sync_dept" class="text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.dingtalk.syncDeptTargetHint") }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Generic OIDC OAuth 登录 -->
<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.oidc.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.oidc.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.oidc.enable")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.oidc.enableHint") }}
</p>
</div>
<Toggle v-model="form.oidc_connect_enabled" />
</div>
<div
v-if="form.oidc_connect_enabled"
class="space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.providerName") }}
</label>
<input
v-model="form.oidc_connect_provider_name"
type="text"
class="input"
:placeholder="
t('admin.settings.oidc.providerNamePlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.clientId") }}
</label>
<input
v-model="form.oidc_connect_client_id"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.clientIdPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.clientSecret") }}
</label>
<input
v-model="form.oidc_connect_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.oidc_connect_client_secret_configured
? t(
'admin.settings.oidc.clientSecretConfiguredPlaceholder',
)
: t('admin.settings.oidc.clientSecretPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.oidc_connect_client_secret_configured
? t("admin.settings.oidc.clientSecretConfiguredHint")
: t("admin.settings.oidc.clientSecretHint")
}}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.issuerUrl") }}
</label>
<input
v-model="form.oidc_connect_issuer_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.issuerUrlPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.discoveryUrl") }}
</label>
<input
v-model="form.oidc_connect_discovery_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.discoveryUrlPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.authorizeUrl") }}
</label>
<input
v-model="form.oidc_connect_authorize_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.authorizeUrlPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.tokenUrl") }}
</label>
<input
v-model="form.oidc_connect_token_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.tokenUrlPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.userinfoUrl") }}
</label>
<input
v-model="form.oidc_connect_userinfo_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.userinfoUrlPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.jwksUrl") }}
</label>
<input
v-model="form.oidc_connect_jwks_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.jwksUrlPlaceholder')"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.scopes") }}
</label>
<input
v-model="form.oidc_connect_scopes"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.oidc.scopesPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.oidc.scopesHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.redirectUrl") }}
</label>
<input
v-model="form.oidc_connect_redirect_url"
type="url"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.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="setAndCopyOIDCRedirectUrl"
>
{{ t("admin.settings.oidc.quickSetCopy") }}
</button>
<code
v-if="oidcRedirectUrlSuggestion"
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"
>
{{ oidcRedirectUrlSuggestion }}
</code>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.oidc.redirectUrlHint") }}
</p>
</div>
<div class="lg:col-span-2">
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.frontendRedirectUrl") }}
</label>
<input
v-model="form.oidc_connect_frontend_redirect_url"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.frontendRedirectUrlPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.oidc.frontendRedirectUrlHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.tokenAuthMethod") }}
</label>
<select
v-model="form.oidc_connect_token_auth_method"
class="input font-mono text-sm"
>
<option value="client_secret_post">
client_secret_post
</option>
<option value="client_secret_basic">
client_secret_basic
</option>
<option value="none">none</option>
</select>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.clockSkewSeconds") }}
</label>
<input
v-model.number="form.oidc_connect_clock_skew_seconds"
type="number"
min="0"
max="600"
class="input"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.allowedSigningAlgs") }}
</label>
<input
v-model="form.oidc_connect_allowed_signing_algs"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.allowedSigningAlgsPlaceholder')
"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div
class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.oidc.usePkce") }}
</label>
</div>
<Toggle
v-model="form.oidc_connect_use_pkce"
data-testid="oidc-connect-use-pkce"
/>
</div>
<div
class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.oidc.validateIdToken") }}
</label>
</div>
<Toggle
v-model="form.oidc_connect_validate_id_token"
data-testid="oidc-connect-validate-id-token"
/>
</div>
<div
class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.oidc.requireEmailVerified") }}
</label>
</div>
<Toggle
v-model="form.oidc_connect_require_email_verified"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.userinfoEmailPath") }}
</label>
<input
v-model="form.oidc_connect_userinfo_email_path"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.userinfoEmailPathPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.userinfoIdPath") }}
</label>
<input
v-model="form.oidc_connect_userinfo_id_path"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.userinfoIdPathPlaceholder')
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.oidc.userinfoUsernamePath") }}
</label>
<input
v-model="form.oidc_connect_userinfo_username_path"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.oidc.userinfoUsernamePathPlaceholder')
"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /Tab: Security — Registration, Turnstile, LinuxDo, OIDC -->
<!-- Tab: Users -->
<div v-show="activeTab === 'users'" class="space-y-6">
<!-- Default Settings -->
<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>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.defaults.defaultUserRpmLimit") }}
</label>
<input
v-model.number="form.default_user_rpm_limit"
type="number"
min="0"
step="1"
class="input"
placeholder="0"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.defaults.defaultUserRpmLimitHint") }}
</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 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.authSourceDefaults.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.authSourceDefaults.description") }}
</p>
</div>
<div class="space-y-6 p-6">
<div
class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.authSourceDefaults.requireEmailLabel") }}
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.authSourceDefaults.requireEmailHint") }}
</p>
</div>
<Toggle v-model="form.force_email_on_third_party_signup" />
</div>
<div class="space-y-4">
<div
v-for="authSource in authSourceDefaultsMeta"
:key="authSource.source"
class="rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="flex items-center justify-between gap-4">
<div>
<div class="font-medium text-gray-900 dark:text-white">
{{ authSource.title }}
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ authSource.description }}
</p>
</div>
<Toggle
v-model="
authSourceDefaults[authSource.source].grant_on_signup
"
:data-testid="`auth-source-${authSource.source}-enabled`"
/>
</div>
<div
v-if="authSourceDefaults[authSource.source].grant_on_signup"
:data-testid="`auth-source-${authSource.source}-panel`"
class="mt-4 space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.authSourceDefaults.enabledHint") }}
</p>
<div class="grid grid-cols-1 gap-4 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="
authSourceDefaults[authSource.source].balance
"
type="number"
step="0.01"
min="0"
class="input"
placeholder="0.00"
/>
</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="
authSourceDefaults[authSource.source].concurrency
"
type="number"
min="1"
class="input"
placeholder="5"
/>
</div>
</div>
<div
class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700"
>
<div>
<label
class="font-medium text-gray-900 dark:text-white"
>
{{ t("admin.settings.authSourceDefaults.grantOnFirstBindLabel") }}
</label>
<p
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.settings.authSourceDefaults.grantOnFirstBindHint") }}
</p>
</div>
<Toggle
v-model="
authSourceDefaults[authSource.source]
.grant_on_first_bind
"
/>
</div>
<div class="mb-3 flex items-center justify-between">
<div>
<label
class="font-medium text-gray-900 dark:text-white"
>
{{ t("admin.settings.authSourceDefaults.defaultSubscriptionsLabel") }}
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.authSourceDefaults.defaultSubscriptionsHint") }}
</p>
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="
addAuthSourceDefaultSubscription(authSource.source)
"
:disabled="subscriptionGroups.length === 0"
>
{{
t("admin.settings.defaults.addDefaultSubscription")
}}
</button>
</div>
<div
v-if="
authSourceDefaults[authSource.source].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.authSourceDefaults.noSourceSubscriptions") }}
</div>
<div v-else class="space-y-3">
<div
v-for="(item, index) in authSourceDefaults[
authSource.source
].subscriptions"
:key="`${authSource.source}-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 w-full text-red-600 hover:text-red-700 dark:text-red-400"
@click="
removeAuthSourceDefaultSubscription(
authSource.source,
index,
)
"
>
{{ t("common.delete") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /Tab: Users -->
<!-- Tab: Gateway — Claude Code, Scheduling -->
<div v-show="activeTab === 'gateway'" class="space-y-6">
<!-- Claude Code Settings -->
<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>
<!-- Gateway Scheduling Settings -->
<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="space-y-5 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>
<Toggle v-model="form.allow_ungrouped_key_scheduling" />
</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.openaiExperimentalScheduler.title") }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t("admin.settings.openaiExperimentalScheduler.description")
}}
</p>
</div>
<Toggle v-model="form.openai_advanced_scheduler_enabled" />
</div>
</div>
</div>
<!-- Gateway Forwarding Behavior -->
<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.gatewayForwarding.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<!-- Fingerprint Unification -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.fingerprintUnification",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.fingerprintUnificationHint",
)
}}
</p>
</div>
<Toggle v-model="form.enable_fingerprint_unification" />
</div>
<!-- Metadata Passthrough -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t("admin.settings.gatewayForwarding.metadataPassthrough")
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.metadataPassthroughHint",
)
}}
</p>
</div>
<Toggle v-model="form.enable_metadata_passthrough" />
</div>
<!-- CCH Signing -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.gatewayForwarding.cchSigning") }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.cchSigningHint") }}
</p>
</div>
<Toggle v-model="form.enable_cch_signing" />
</div>
<!-- Anthropic Cache TTL 1h Injection -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.anthropicCacheTTL1hInjection",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.anthropicCacheTTL1hInjectionHint",
)
}}
</p>
</div>
<Toggle
v-model="form.enable_anthropic_cache_ttl_1h_injection"
/>
</div>
<!-- messages cache_control 改写 -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.rewriteMessageCacheControl",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.rewriteMessageCacheControlHint",
)
}}
</p>
</div>
<Toggle v-model="form.rewrite_message_cache_control" />
</div>
<!-- Antigravity UA 版本 -->
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.antigravityUserAgentVersion",
)
}}
</label>
<input
v-model="form.antigravity_user_agent_version"
type="text"
class="input max-w-xs font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.antigravityUserAgentVersionPlaceholder',
)
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.antigravityUserAgentVersionHint",
)
}}
</p>
</div>
</div>
</div>
<!-- Web Search Emulation -->
<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.webSearchEmulation.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.webSearchEmulation.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<!-- Global Toggle -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.webSearchEmulation.enabled") }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.webSearchEmulation.enabledHint") }}
</p>
</div>
<Toggle v-model="webSearchConfig.enabled" />
</div>
<!-- Providers -->
<div v-if="webSearchConfig.enabled" class="space-y-4">
<div class="flex items-center justify-between">
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.webSearchEmulation.providers") }}
</label>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="addWebSearchProvider"
>
{{ t("admin.settings.webSearchEmulation.addProvider") }}
</button>
</div>
<div
v-if="webSearchConfig.providers.length === 0"
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-400 dark:border-dark-600"
>
{{ t("admin.settings.webSearchEmulation.noProviders") }}
</div>
<div
v-for="(provider, pIdx) in webSearchConfig.providers"
:key="pIdx"
class="rounded-lg border border-gray-200 dark:border-dark-600"
>
<!-- Collapsible header -->
<div
class="flex cursor-pointer items-center justify-between px-4 py-3"
@click="toggleProviderExpand(pIdx)"
>
<div class="flex items-center gap-3">
<svg
class="h-4 w-4 text-gray-400 transition-transform"
:class="{ 'rotate-90': expandedProviders[pIdx] }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<Select
v-model="provider.type"
:options="[
{ value: 'brave', label: 'Brave Search' },
{ value: 'tavily', label: 'Tavily' },
]"
class="w-36"
@click.stop
/>
<!-- Quota summary (always visible) -->
<span class="text-xs text-gray-400">
{{ provider.quota_used ?? 0 }} /
{{
provider.quota_limit != null &&
provider.quota_limit > 0
? provider.quota_limit
: "∞"
}}
</span>
<span
v-if="
!expandedProviders[pIdx] &&
provider.api_key_configured
"
class="text-xs text-green-500"
>
{{
t(
"admin.settings.webSearchEmulation.apiKeyConfigured",
)
}}
</span>
</div>
<button
type="button"
class="text-red-500 hover:text-red-700 text-xs"
@click.stop="removeWebSearchProvider(pIdx)"
>
{{
t("admin.settings.webSearchEmulation.removeProvider")
}}
</button>
</div>
<!-- Expanded content -->
<div
v-if="expandedProviders[pIdx]"
class="space-y-3 border-t border-gray-100 px-4 pb-4 pt-3 dark:border-dark-700"
>
<!-- API Key with inline show/copy -->
<div>
<label class="text-xs text-gray-500">{{
t("admin.settings.webSearchEmulation.apiKey")
}}</label>
<div class="relative">
<input
v-model="provider.api_key"
:type="apiKeyVisible[pIdx] ? 'text' : 'password'"
class="input w-full text-sm"
:class="
provider.api_key || provider.api_key_configured
? 'pr-16'
: ''
"
:placeholder="
provider.api_key_configured
? '••••••••'
: t(
'admin.settings.webSearchEmulation.apiKeyPlaceholder',
)
"
/>
<div
v-if="provider.api_key || provider.api_key_configured"
class="absolute inset-y-0 right-0 flex items-center pr-1.5"
>
<button
type="button"
class="rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:title="
apiKeyVisible[pIdx]
? t(
'admin.settings.webSearchEmulation.hideApiKey',
)
: t(
'admin.settings.webSearchEmulation.showApiKey',
)
"
@click="apiKeyVisible[pIdx] = !apiKeyVisible[pIdx]"
>
<svg
v-if="!apiKeyVisible[pIdx]"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<svg
v-else
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"
/>
</svg>
</button>
<button
type="button"
class="rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:class="{
'opacity-30 cursor-not-allowed':
!provider.api_key,
}"
:title="
t('admin.settings.webSearchEmulation.copyApiKey')
"
:disabled="!provider.api_key"
@click="copyApiKey(pIdx)"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Quota + Subscription in compact row -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">{{
t("admin.settings.webSearchEmulation.quotaLimit")
}}</label>
<input
v-model="provider.quota_limit"
type="number"
min="1"
class="input text-sm"
:placeholder="'∞'"
/>
<p class="mt-0.5 text-xs text-gray-400">
{{
t(
"admin.settings.webSearchEmulation.quotaLimitHint",
)
}}
</p>
</div>
<div>
<label class="text-xs text-gray-500">{{
t("admin.settings.webSearchEmulation.subscribedAt")
}}</label>
<input
:value="formatSubscribedAt(provider.subscribed_at)"
type="date"
class="input text-sm"
@input="
provider.subscribed_at = parseSubscribedAt(
($event.target as HTMLInputElement).value,
)
"
/>
<p class="mt-0.5 text-xs text-gray-400">
{{
t(
"admin.settings.webSearchEmulation.subscribedAtHint",
)
}}
</p>
</div>
</div>
<!-- Usage display -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500"
>{{
t("admin.settings.webSearchEmulation.quotaUsage")
}}:</span
>
<div
v-if="
provider.quota_limit != null &&
provider.quota_limit > 0
"
class="flex-1 rounded-full bg-gray-200 dark:bg-dark-600"
style="height: 6px"
>
<div
class="h-full rounded-full transition-all"
:class="
quotaPercentage(provider) > 90
? 'bg-red-500'
: quotaPercentage(provider) > 70
? 'bg-yellow-500'
: 'bg-green-500'
"
:style="{
width:
Math.min(quotaPercentage(provider), 100) + '%',
}"
/>
</div>
<div v-else class="flex-1" />
<span class="text-xs text-gray-500"
>{{ provider.quota_used ?? 0 }} /
{{
provider.quota_limit != null &&
provider.quota_limit > 0
? provider.quota_limit
: "∞"
}}</span
>
<button
v-if="(provider.quota_used ?? 0) > 0"
type="button"
class="text-xs text-primary-600 hover:text-primary-700"
@click="resetWebSearchUsage(pIdx)"
>
{{ t("admin.settings.webSearchEmulation.resetUsage") }}
</button>
</div>
<!-- Proxy + Test on same row -->
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="text-xs text-gray-500">{{
t("admin.settings.webSearchEmulation.proxy")
}}</label>
<ProxySelector
v-model="provider.proxy_id"
:proxies="webSearchProxies"
/>
</div>
<button
type="button"
class="btn btn-secondary btn-sm whitespace-nowrap"
@click="openTestDialog()"
>
{{ t("admin.settings.webSearchEmulation.test") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Web Search Test Dialog -->
<div
v-if="wsTestDialogOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="wsTestDialogOpen = false"
>
<div
class="mx-4 w-full max-w-lg rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800"
>
<h3
class="mb-4 text-lg font-semibold text-gray-900 dark:text-white"
>
{{ t("admin.settings.webSearchEmulation.testResultTitle") }}
</h3>
<div class="flex items-center gap-2">
<input
v-model="wsTestQuery"
type="text"
class="input flex-1 text-sm"
:placeholder="
t('admin.settings.webSearchEmulation.testDefaultQuery')
"
@keyup.enter="testWebSearchProvider()"
/>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="wsTestLoading"
@click="testWebSearchProvider()"
>
{{
wsTestLoading
? t("admin.settings.webSearchEmulation.testing")
: t("admin.settings.webSearchEmulation.test")
}}
</button>
</div>
<!-- Test results -->
<div
v-if="wsTestResult"
class="mt-4 max-h-80 overflow-y-auto rounded-lg bg-gray-50 p-4 dark:bg-dark-700"
>
<p
class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t("admin.settings.webSearchEmulation.testResultProvider")
}}: {{ wsTestResult.provider }}
</p>
<div
v-if="wsTestResult.results.length === 0"
class="text-sm text-gray-400"
>
{{ t("admin.settings.webSearchEmulation.testNoResults") }}
</div>
<div
v-for="(r, rIdx) in wsTestResult.results"
:key="rIdx"
class="mt-2 border-t border-gray-200 pt-2 first:mt-0 first:border-0 first:pt-0 dark:border-dark-600"
>
<a
:href="r.url"
target="_blank"
class="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>{{ r.title }}</a
>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ r.snippet }}
</p>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
type="button"
class="btn btn-secondary btn-sm"
@click="wsTestDialogOpen = false"
>
{{ t("common.close") }}
</button>
</div>
</div>
</div>
</div>
<!-- /Tab: Gateway — Claude Code, Scheduling -->
<!-- Tab: General -->
<div v-show="activeTab === 'general'" class="space-y-6">
<!-- Site Settings -->
<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">
<!-- Backend Mode -->
<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>
<!-- API Base URL -->
<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>
<!-- Global Table Preferences -->
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.site.tablePreferencesTitle") }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.site.tablePreferencesDescription") }}
</p>
<div class="mt-4 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.tableDefaultPageSize") }}
</label>
<input
v-model.number="form.table_default_page_size"
type="number"
min="5"
max="1000"
step="1"
class="input w-40"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.site.tableDefaultPageSizeHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.site.tablePageSizeOptions") }}
</label>
<input
v-model="tablePageSizeOptionsInput"
type="text"
class="input font-mono text-sm"
:placeholder="
t('admin.settings.site.tablePageSizeOptionsPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.site.tablePageSizeOptionsHint") }}
</p>
</div>
</div>
</div>
<!-- Custom Endpoints -->
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.site.customEndpoints.title") }}
</label>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.site.customEndpoints.description") }}
</p>
<div class="space-y-3">
<div
v-for="(ep, index) in form.custom_endpoints"
:key="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.site.customEndpoints.itemLabel", {
n: index + 1,
})
}}
</span>
<button
type="button"
class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeEndpoint(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 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.site.customEndpoints.name") }}
</label>
<input
v-model="ep.name"
type="text"
class="input text-sm"
:placeholder="
t(
'admin.settings.site.customEndpoints.namePlaceholder',
)
"
/>
</div>
<div>
<label
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t("admin.settings.site.customEndpoints.endpointUrl")
}}
</label>
<input
v-model="ep.endpoint"
type="url"
class="input font-mono text-sm"
:placeholder="
t(
'admin.settings.site.customEndpoints.endpointUrlPlaceholder',
)
"
/>
</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.site.customEndpoints.descriptionLabel",
)
}}
</label>
<input
v-model="ep.description"
type="text"
class="input text-sm"
:placeholder="
t(
'admin.settings.site.customEndpoints.descriptionPlaceholder',
)
"
/>
</div>
</div>
</div>
</div>
<button
type="button"
class="mt-3 flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 px-4 py-2.5 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="addEndpoint"
>
<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.site.customEndpoints.add") }}
</button>
</div>
<!-- Contact Info -->
<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>
<!-- Doc URL -->
<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>
<!-- Site Logo Upload -->
<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>
<!-- Home Content -->
<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>
<!-- iframe CSP Warning -->
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
{{ t("admin.settings.site.homeContentIframeWarning") }}
</p>
</div>
<!-- Hide CCS Import Button -->
<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>
<!-- Custom Menu Items -->
<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">
<!-- Existing menu items -->
<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">
<!-- Move up -->
<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>
<!-- Move down -->
<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>
<!-- Delete -->
<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">
<!-- Label -->
<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>
<!-- Visibility -->
<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>
<!-- URL (full width) -->
<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>
<!-- SVG Icon (full width) -->
<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>
<!-- Add button -->
<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>
<!-- /Tab: General -->
<!-- Tab: Login Agreement -->
<div v-show="activeTab === 'agreement'" class="space-y-6">
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ localText("登录条款确认", "Login agreement") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"控制登录页是否要求用户先阅读并同意服务条款、隐私政策或其他 Markdown 文档。",
"Control whether the login page requires users to accept Markdown policy documents first.",
)
}}
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ form.login_agreement_enabled ? localText("已启用", "Enabled") : localText("未启用", "Disabled") }}
</span>
<Toggle v-model="form.login_agreement_enabled" />
</div>
</div>
</div>
<div class="space-y-6 p-6">
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_220px]">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("展示形式", "Display mode") }}
</label>
<div class="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.login_agreement_mode === 'modal'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.login_agreement_mode = 'modal'"
>
<Icon name="shield" size="sm" />
{{ localText("弹窗", "Modal") }}
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.login_agreement_mode === 'checkbox'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.login_agreement_mode = 'checkbox'"
>
<Icon name="checkCircle" size="sm" />
{{ localText("复选框", "Checkbox") }}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.login_agreement_mode === "checkbox"
? localText("复选框会显示在登录按钮下方,未勾选前所有登录入口禁用。", "The checkbox appears below the login button and gates all login actions.")
: localText("弹窗会在登录页打开,用户拒绝后所有登录入口保持禁用。", "The modal opens on the login page and gates all login actions until accepted.")
}}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("条款更新日期", "Updated date") }}
</label>
<input
v-model="form.login_agreement_updated_at"
type="date"
class="input"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ localText("日期或文档内容变化后,用户需要重新同意。", "Changing the date or content requires fresh consent.") }}
</p>
</div>
</div>
<div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ localText("协议文档", "Agreement documents") }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
localText(
"文档名称可自定义,内容按 Markdown 保存。可参考:服务条款、使用政策、支持的国家和地区、服务特定条款。",
"Document titles are customizable and content is saved as Markdown.",
)
}}
</p>
</div>
<button
type="button"
class="btn btn-primary btn-sm inline-flex items-center gap-1.5"
@click="addLoginAgreementDocument"
>
<Icon name="plus" size="sm" />
{{ localText("添加文档", "Add document") }}
</button>
</div>
<div class="mt-4 space-y-3">
<div
v-for="(doc, index) in form.login_agreement_documents"
:key="doc.id || index"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800/60"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-200">
<Icon
:name="
index === 1
? 'shield'
: index === 2
? 'globe'
: index === 3
? 'cog'
: 'document'
"
size="sm"
/>
</span>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-white">
{{ doc.title || localText("未命名文档", "Untitled document") }}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ loginAgreementRoutePath(doc, index) }}
</p>
</div>
</div>
<button
type="button"
class="rounded-md p-2 text-red-400 transition hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-900/20"
:disabled="
form.login_agreement_enabled &&
form.login_agreement_documents.length <= 1
"
@click="removeLoginAgreementDocument(index)"
>
<Icon name="trash" size="sm" />
</button>
</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("文档名称", "Document title") }}
</label>
<input
v-model="doc.title"
type="text"
class="input text-sm"
:placeholder="localText('例如:服务条款', 'Example: Terms of Service')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("路由标识", "Route slug") }}
</label>
<div class="flex overflow-hidden rounded-lg border border-gray-300 bg-white focus-within:border-primary-500 focus-within:ring-1 focus-within:ring-primary-500 dark:border-dark-600 dark:bg-dark-900">
<span class="inline-flex flex-shrink-0 items-center border-r border-gray-200 bg-gray-50 px-3 text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-800 dark:text-dark-400">
/legal/
</span>
<input
v-model="doc.id"
type="text"
class="min-w-0 flex-1 border-0 bg-transparent px-3 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-dark-500"
placeholder="usage-policy"
/>
</div>
</div>
</div>
<div class="mt-3">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("Markdown 内容", "Markdown content") }}
</label>
<textarea
v-model="doc.content_md"
rows="8"
class="input font-mono text-sm"
:placeholder="localText('在这里填写正式 Markdown 内容。', 'Write the final Markdown content here.')"
></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /Tab: Login Agreement -->
<!-- Tab: Features (功能开关) -->
<div v-show="activeTab === 'features'" 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.features.channelMonitor.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/monitor"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.channelMonitor.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 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.features.channelMonitor.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.enabledHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
</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.features.availableChannels.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/pricing"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.availableChannels.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 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.features.availableChannels.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.enabledHint') }}
</p>
</div>
<Toggle v-model="form.available_channels_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.features.riskControl.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.riskControl.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/risk-control"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.riskControl.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 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.features.riskControl.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.riskControl.enabledHint') }}
</p>
</div>
<Toggle v-model="form.risk_control_enabled" />
</div>
</div>
</div>
<!-- Affiliate (邀请返利) feature card -->
<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.features.affiliate.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.description') }}
</p>
</div>
<div class="space-y-5 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.features.affiliate.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.enabledHint') }}
</p>
</div>
<Toggle v-model="form.affiliate_enabled" />
</div>
<div v-if="form.affiliate_enabled" class="space-y-6">
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.rebateRate') }}
</label>
<div class="relative">
<input
v-model.number="form.affiliate_rebate_rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
placeholder="20"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.rebateRateHint') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.freezeHours') }}
</label>
<input
v-model.number="form.affiliate_rebate_freeze_hours"
type="number"
step="1"
min="0"
max="720"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.freezeHoursDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.durationDays') }}
</label>
<input
v-model.number="form.affiliate_rebate_duration_days"
type="number"
step="1"
min="0"
max="3650"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.durationDaysDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.perInviteeCap') }}
</label>
<input
v-model.number="form.affiliate_rebate_per_invitee_cap"
type="number"
step="0.01"
min="0"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.perInviteeCapDesc') }}
</p>
</div>
<!-- 专属用户管理 -->
<div class="border-t border-gray-100 pt-6 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.affiliate.customUsers.title') }}
</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.customUsers.description') }}
</p>
</div>
<button
type="button"
class="btn btn-primary btn-sm"
@click="openAffiliateModal(null)"
>
+ {{ t('admin.settings.features.affiliate.customUsers.addButton') }}
</button>
</div>
<div class="mb-3 flex items-center gap-2">
<input
v-model="affiliateState.search"
type="text"
class="input flex-1"
:placeholder="t('admin.settings.features.affiliate.customUsers.searchPlaceholder')"
@input="onAffiliateSearchInput"
/>
<button
v-if="affiliateState.selected.length > 0"
type="button"
class="btn btn-secondary btn-sm"
@click="openAffiliateBatchModal"
>
{{ t('admin.settings.features.affiliate.customUsers.batchButton', { count: affiliateState.selected.length }) }}
</button>
</div>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-800">
<tr>
<th class="px-3 py-2 text-left">
<input
type="checkbox"
:checked="affiliateState.entries.length > 0 && affiliateState.selected.length === affiliateState.entries.length"
@change="toggleAffiliateSelectAll"
/>
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.email') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.username') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.code') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.rate') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-if="affiliateState.loading">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</td>
</tr>
<tr v-else-if="affiliateState.entries.length === 0">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('admin.settings.features.affiliate.customUsers.empty') }}
</td>
</tr>
<tr v-for="entry in affiliateState.entries" :key="entry.user_id">
<td class="px-3 py-2">
<input
type="checkbox"
:checked="affiliateState.selected.includes(entry.user_id)"
@change="toggleAffiliateSelect(entry.user_id)"
/>
</td>
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white">{{ entry.email }}</td>
<td class="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">{{ entry.username }}</td>
<td class="px-3 py-2 text-sm font-mono">
{{ entry.aff_code }}
<span
v-if="entry.aff_code_custom"
class="ml-1 inline-block rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>{{ t('admin.settings.features.affiliate.customUsers.customBadge') }}</span>
</td>
<td class="px-3 py-2 text-sm">
<span v-if="entry.aff_rebate_rate_percent != null">{{ entry.aff_rebate_rate_percent }}%</span>
<span v-else class="text-gray-400">{{ t('admin.settings.features.affiliate.customUsers.useGlobal') }}</span>
</td>
<td class="px-3 py-2 text-sm">
<div class="flex items-center gap-2">
<button type="button" class="text-primary-600 hover:underline" @click="openAffiliateModal(entry)">
{{ t('common.edit') }}
</button>
<button
type="button"
class="text-red-600 hover:underline"
@click="askResetAffiliateUser(entry)"
>
{{ t('common.delete') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="affiliateState.total > affiliateState.pageSize" class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-500">
{{ t('admin.settings.features.affiliate.customUsers.totalLabel', { total: affiliateState.total }) }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="affiliateState.page <= 1"
@click="changeAffiliatePage(affiliateState.page - 1)"
>
{{ t('pagination.previous') }}
</button>
<span class="text-gray-500">{{ affiliateState.page }} / {{ Math.max(1, Math.ceil(affiliateState.total / affiliateState.pageSize)) }}</span>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="affiliateState.page >= Math.ceil(affiliateState.total / affiliateState.pageSize)"
@click="changeAffiliatePage(affiliateState.page + 1)"
>
{{ t('pagination.next') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Affiliate add/edit modal -->
<div
v-if="affiliateModal.open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeAffiliateModal"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
<h3 class="mb-4 text-lg font-semibold">
{{ affiliateModal.mode === 'add' ? t('admin.settings.features.affiliate.modal.addTitle') : t('admin.settings.features.affiliate.modal.editTitle') }}
</h3>
<div class="space-y-4">
<div v-if="affiliateModal.mode === 'add'">
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
<!-- Chip showing the picked user; clicking it re-opens the search -->
<div
v-if="affiliateModal.selectedUser"
class="flex items-center justify-between rounded-md border border-primary-200 bg-primary-50 px-3 py-2 dark:border-primary-700/50 dark:bg-primary-900/20"
>
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ affiliateModal.selectedUser.email }}</span>
<span class="ml-1 text-xs text-gray-500">({{ affiliateModal.selectedUser.username }})</span>
</div>
<button
type="button"
class="text-lg leading-none text-gray-400 hover:text-red-600"
:title="t('admin.settings.features.affiliate.modal.changeUser')"
@click="clearSelectedAffiliateUser"
>
×
</button>
</div>
<!-- Search input + result dropdown — hidden once a selection is made -->
<template v-else>
<input
v-model="affiliateModal.userQuery"
type="text"
class="input"
:placeholder="t('admin.settings.features.affiliate.modal.userPlaceholder')"
@input="onAffiliateUserSearchInput"
/>
<div
v-if="affiliateModal.userResults.length > 0"
class="mt-1 max-h-40 overflow-y-auto rounded border border-gray-200 dark:border-dark-700"
>
<button
v-for="u in affiliateModal.userResults"
:key="u.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-800"
@click="selectAffiliateUser(u)"
>
{{ u.email }} <span class="text-xs text-gray-500">({{ u.username }})</span>
</button>
</div>
</template>
</div>
<div v-else>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
<input
type="text"
class="input"
:value="affiliateModal.editingEntry ? affiliateModal.editingEntry.email : ''"
disabled
/>
</div>
<div>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.codeLabel') }}</label>
<input
v-model="affiliateModal.code"
type="text"
class="input font-mono"
:placeholder="t('admin.settings.features.affiliate.modal.codePlaceholder')"
maxlength="32"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.modal.codeHint') }}
</p>
</div>
<div>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.rateLabel') }}</label>
<div class="relative">
<input
v-model="affiliateModal.rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
:placeholder="t('admin.settings.features.affiliate.modal.ratePlaceholder')"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.modal.rateHint') }}
</p>
</div>
</div>
<div class="mt-6 flex items-center justify-between gap-3">
<p
v-if="!affiliateModalCanSubmit"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.features.affiliate.modal.errorEmpty') }}
</p>
<span v-else></span>
<div class="flex gap-2">
<button type="button" class="btn btn-secondary" @click="closeAffiliateModal">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="affiliateModal.saving || !affiliateModalCanSubmit"
@click="submitAffiliateModal"
>
{{ affiliateModal.saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div>
<!-- Affiliate batch rate modal -->
<div
v-if="affiliateBatchModal.open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="affiliateBatchModal.open = false"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
<h3 class="mb-4 text-lg font-semibold">
{{ t('admin.settings.features.affiliate.batchModal.title', { count: affiliateState.selected.length }) }}
</h3>
<p class="mb-4 text-sm text-gray-500">
{{ t('admin.settings.features.affiliate.batchModal.hint') }}
</p>
<div class="relative">
<input
v-model="affiliateBatchModal.rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
:placeholder="t('admin.settings.features.affiliate.batchModal.placeholder')"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-2 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.batchModal.clearHint') }}
</p>
<div class="mt-6 flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="affiliateBatchModal.open = false">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="affiliateBatchModal.saving"
@click="submitAffiliateBatchModal"
>
{{ affiliateBatchModal.saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div><!-- /Tab: Features -->
<!-- Tab: Email -->
<!-- Tab: Payment -->
<div v-show="activeTab === 'payment'" class="space-y-6">
<!-- Payment System Settings -->
<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.payment.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.payment.description") }}
<a
:href="paymentGuideHref"
target="_blank"
rel="noopener noreferrer"
class="ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<svg
class="mr-0.5 h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
{{ t("admin.settings.payment.configGuide") }}
</a>
</p>
</div>
<div class="space-y-4 p-6">
<!-- Enable toggle -->
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.payment.enabled")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.payment.enabledHint") }}
</p>
</div>
<Toggle v-model="form.payment_enabled" />
</div>
<template v-if="form.payment_enabled">
<!-- Row 1: Product name -->
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">{{
t("admin.settings.payment.productNamePrefix")
}}</label
><input
v-model="form.payment_product_name_prefix"
type="text"
class="input"
placeholder="Sub2API"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.productNameSuffix")
}}</label
><input
v-model="form.payment_product_name_suffix"
type="text"
class="input"
placeholder="CNY"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.preview")
}}</label>
<div
class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300"
>
{{
(form.payment_product_name_prefix || "Sub2API") +
" 100 " +
(form.payment_product_name_suffix || "CNY")
}}
</div>
</div>
</div>
<!-- Row 2: Balance toggle + amounts -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div>
<label class="input-label">{{
t("admin.settings.payment.minAmount")
}}</label
><input
:value="form.payment_min_amount || ''"
@input="
form.payment_min_amount =
parseFloat(
($event.target as HTMLInputElement).value,
) || 0
"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.settings.payment.noLimit')"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.maxAmount")
}}</label
><input
:value="form.payment_max_amount || ''"
@input="
form.payment_max_amount =
parseFloat(
($event.target as HTMLInputElement).value,
) || 0
"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.settings.payment.noLimit')"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.dailyLimit")
}}</label
><input
:value="form.payment_daily_limit || ''"
@input="
form.payment_daily_limit =
parseFloat(
($event.target as HTMLInputElement).value,
) || 0
"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.settings.payment.noLimit')"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.balanceRechargeMultiplier")
}}</label>
<input
:value="form.payment_balance_recharge_multiplier || ''"
@input="
form.payment_balance_recharge_multiplier =
parseFloat(
($event.target as HTMLInputElement).value,
) || 1
"
type="number"
step="0.01"
min="0.01"
class="input"
/>
<p class="mt-0.5 text-xs text-gray-400">
{{
t(
"admin.settings.payment.balanceRechargeMultiplierHint",
)
}}
</p>
<p
class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400"
>
{{
t("admin.settings.payment.balanceRechargePreview", {
usd: (
Number(form.payment_balance_recharge_multiplier) ||
1
).toFixed(2),
})
}}
</p>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.rechargeFeeRate")
}}</label>
<div class="relative">
<input
:value="form.payment_recharge_fee_rate ?? ''"
@input="
form.payment_recharge_fee_rate = Math.min(
100,
Math.max(
0,
Math.round(
parseFloat(
($event.target as HTMLInputElement).value ||
'0',
) * 100,
) / 100,
),
)
"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
/>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400"
>%</span
>
</div>
<p class="mt-0.5 text-xs text-gray-400">
{{ t("admin.settings.payment.rechargeFeeRateHint") }}
</p>
<p
v-if="(Number(form.payment_recharge_fee_rate) || 0) > 0"
class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400"
>
{{
t("admin.settings.payment.rechargeFeePreview", {
fee: (
Number(form.payment_recharge_fee_rate) || 0
).toFixed(2),
})
}}
</p>
</div>
<div>
<label class="input-label"
>{{ t("admin.settings.payment.orderTimeout") }}
<span class="text-red-500">*</span></label
><input
v-model.number="form.payment_order_timeout_minutes"
type="number"
min="1"
class="input"
required
/>
<p class="mt-0.5 text-xs text-gray-400">
{{ t("admin.settings.payment.orderTimeoutHint") }}
</p>
</div>
</div>
<!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) -->
<div class="flex flex-wrap items-end gap-4">
<div class="w-28">
<label class="input-label">{{
t("admin.settings.payment.maxPendingOrders")
}}</label
><input
v-model.number="form.payment_max_pending_orders"
type="number"
min="1"
class="input"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.loadBalanceStrategy")
}}</label>
<Select
v-model="form.payment_load_balance_strategy"
:options="loadBalanceOptions"
class="w-40"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.cancelRateLimit")
}}</label>
<div class="flex items-center gap-2">
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
form.payment_cancel_rate_limit_enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
@click="
form.payment_cancel_rate_limit_enabled =
!form.payment_cancel_rate_limit_enabled
"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
form.payment_cancel_rate_limit_enabled
? 'translate-x-5'
: 'translate-x-0',
]"
/>
</button>
<Select
v-model="form.payment_cancel_rate_limit_window_mode"
:options="cancelRateLimitModeOptions"
class="w-24"
:disabled="!form.payment_cancel_rate_limit_enabled"
/>
<span
:class="[
'text-sm whitespace-nowrap',
form.payment_cancel_rate_limit_enabled
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-600',
]"
>{{
t("admin.settings.payment.cancelRateLimitEvery")
}}</span
>
<input
v-model.number="form.payment_cancel_rate_limit_window"
type="number"
min="1"
required
class="input w-14 text-center"
:disabled="!form.payment_cancel_rate_limit_enabled"
/>
<Select
v-model="form.payment_cancel_rate_limit_unit"
:options="cancelRateLimitUnitOptions"
class="w-28"
:disabled="!form.payment_cancel_rate_limit_enabled"
/>
<span
:class="[
'text-sm whitespace-nowrap',
form.payment_cancel_rate_limit_enabled
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-600',
]"
>{{
t("admin.settings.payment.cancelRateLimitAllowMax")
}}</span
>
<input
v-model.number="form.payment_cancel_rate_limit_max"
type="number"
min="1"
required
class="input w-14 text-center"
:disabled="!form.payment_cancel_rate_limit_enabled"
/>
<span
:class="[
'text-sm whitespace-nowrap',
form.payment_cancel_rate_limit_enabled
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-600',
]"
>{{
t("admin.settings.payment.cancelRateLimitTimes")
}}</span
>
</div>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.alipayForceQRCode")
}}</label>
<div class="flex items-center gap-2">
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
form.payment_alipay_force_qrcode
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
@click="
form.payment_alipay_force_qrcode =
!form.payment_alipay_force_qrcode
"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
form.payment_alipay_force_qrcode
? 'translate-x-5'
: 'translate-x-0',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">{{
t("admin.settings.payment.alipayForceQRCodeHint")
}}</span>
</div>
</div>
</div>
<!-- Row 4: Enabled payment types (provider badges like sub2apipay) -->
<div>
<label class="input-label">{{
t("admin.settings.payment.enabledPaymentTypes")
}}</label>
<div class="mt-1.5 flex flex-wrap gap-2">
<button
v-for="pt in allPaymentTypes"
:key="pt.value"
type="button"
@click="togglePaymentType(pt.value)"
:class="[
'rounded-lg border px-3 py-1.5 text-sm font-medium transition-all',
isPaymentTypeEnabled(pt.value)
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
]"
>
{{ pt.label }}
</button>
</div>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
<a
:href="paymentMethodsHref"
target="_blank"
rel="noopener noreferrer"
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t("admin.settings.payment.findProvider") }}
<svg
class="mb-0.5 ml-0.5 inline h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</p>
</div>
<!-- Row 5: Help image + text -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="input-label">{{
t("admin.settings.payment.helpImage")
}}</label>
<ImageUpload
v-model="form.payment_help_image_url"
:upload-label="t('admin.settings.site.uploadImage')"
:remove-label="t('admin.settings.site.remove')"
:placeholder="
t('admin.settings.payment.helpImagePlaceholder')
"
/>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.helpText")
}}</label>
<textarea
v-model="form.payment_help_text"
rows="3"
class="input"
:placeholder="
t('admin.settings.payment.helpTextPlaceholder')
"
></textarea>
</div>
</div>
</template>
</div>
</div>
<!-- Provider Management -->
<PaymentProviderList
v-if="form.payment_enabled"
:providers="providers"
:loading="providersLoading"
:can-create="hasAnyPaymentTypeEnabled"
:enabled-payment-types="form.payment_enabled_types"
:all-payment-types="allPaymentTypes"
:redirect-label="t('admin.settings.payment.easypayRedirect')"
@refresh="loadProviders"
@create="openCreateProvider"
@edit="openEditProvider"
@delete="confirmDeleteProvider"
@toggle-field="handleToggleField"
@toggle-type="handleToggleType"
@reorder="handleReorderProviders"
/>
</div>
<div v-show="activeTab === 'email'" class="space-y-6">
<!-- Email disabled hint - show when email_verify_enabled is off -->
<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>
<!-- SMTP Settings - Only show when email verification is enabled -->
<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 || loadFailed"
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"
autocomplete="new-password"
autocapitalize="off"
spellcheck="false"
@keydown="smtpPasswordManuallyEdited = true"
@paste="smtpPasswordManuallyEdited = true"
: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>
<!-- Use TLS Toggle -->
<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>
<!-- Send Test Email - Only show when email verification is enabled -->
<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">
{{ 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 || loadFailed
"
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>
<!-- Balance Low Notification -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h3 class="text-base font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.balanceNotify.title") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.balanceNotify.description") }}
</p>
</div>
<div class="px-6 py-6 space-y-4">
<div class="flex items-center justify-between">
<label
class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300"
>{{ t("admin.settings.balanceNotify.enabled") }}</label
>
<Toggle v-model="form.balance_low_notify_enabled" />
</div>
<div v-if="form.balance_low_notify_enabled">
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>{{ t("admin.settings.balanceNotify.threshold") }}</label
>
<div class="relative">
<span
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
>$</span
>
<input
v-model.number="form.balance_low_notify_threshold"
type="number"
min="0"
step="0.01"
class="input pl-7"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.balanceNotify.thresholdHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>{{ t("admin.settings.balanceNotify.rechargeUrl") }}</label
>
<input
v-model="form.balance_low_notify_recharge_url"
type="url"
class="input"
:placeholder="currentOrigin"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.balanceNotify.rechargeUrlHint") }}
</p>
</div>
</div>
</div>
<!-- Account Quota Notification -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h3 class="text-base font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.quotaNotify.title") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.quotaNotify.description") }}
</p>
</div>
<div class="px-6 py-6 space-y-4">
<div class="flex items-center justify-between">
<label
class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300"
>{{ t("admin.settings.quotaNotify.enabled") }}</label
>
<Toggle v-model="form.account_quota_notify_enabled" />
</div>
<div v-if="form.account_quota_notify_enabled">
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>{{ t("admin.settings.quotaNotify.emails") }}</label
>
<div class="space-y-2">
<div
v-for="(entry, index) in form.account_quota_notify_emails ||
[]"
:key="index"
class="flex items-center gap-2"
>
<label
class="relative inline-flex items-center cursor-pointer shrink-0"
>
<input
type="checkbox"
:checked="!entry.disabled"
@change="entry.disabled = !entry.disabled"
class="sr-only peer"
/>
<div
class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"
></div>
</label>
<input
v-model="entry.email"
type="email"
class="input flex-1"
:placeholder="
t('admin.settings.quotaNotify.emailPlaceholder')
"
/>
<button
@click="form.account_quota_notify_emails.splice(index, 1)"
class="btn btn-secondary px-2"
type="button"
>
<Icon name="x" size="xs" class="h-4 w-4" />
</button>
</div>
<button
@click="addQuotaNotifyEmail"
class="btn btn-secondary btn-sm"
type="button"
>
+ {{ t("admin.settings.quotaNotify.addEmail") }}
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.quotaNotify.emailsHint") }}
</p>
</div>
</div>
</div>
</div>
<!-- /Tab: Email -->
<!-- Tab: Backup -->
<div v-show="activeTab === 'backup'">
<BackupSettings />
</div>
<!-- Save Button -->
<div v-show="activeTab !== 'backup'" class="flex justify-end">
<button
type="submit"
:disabled="saving || loadFailed"
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>
<!-- Provider dialogs placed outside the settings form to prevent form submission bubbling -->
<PaymentProviderDialog
ref="providerDialogRef"
:show="showProviderDialog"
:saving="providerSaving"
:editing="editingProvider"
:all-key-options="providerKeyOptions"
:enabled-key-options="enabledProviderKeyOptions"
:all-payment-types="allPaymentTypes"
:redirect-label="t('admin.settings.payment.easypayRedirect')"
@close="showProviderDialog = false"
@save="handleSaveProvider"
/>
<ConfirmDialog
:show="showDeleteProviderDialog"
:title="t('admin.settings.payment.deleteProvider')"
:message="t('admin.settings.payment.deleteProviderConfirm')"
:confirm-text="t('common.delete')"
danger
@confirm="handleDeleteProvider"
@cancel="showDeleteProviderDialog = false"
/>
<ConfirmDialog
:show="affiliateConfirmDialog.show"
:title="affiliateConfirmDialog.title"
:message="affiliateConfirmDialog.message"
:confirm-text="affiliateConfirmDialog.confirmText"
danger
@confirm="handleAffiliateConfirm"
@cancel="cancelAffiliateConfirm"
/>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { adminAPI } from "@/api";
import {
appendAuthSourceDefaultsToUpdateRequest,
buildAuthSourceDefaultsState,
defaultWeChatConnectScopesForMode,
deriveWeChatConnectStoredMode,
normalizeDefaultSubscriptionSettings,
resolveWeChatConnectModeCapabilities,
} from "@/api/admin/settings";
import type {
AuthSourceDefaultsState,
AuthSourceType,
SystemSettings,
UpdateSettingsRequest,
DefaultSubscriptionSetting,
OpenAIFastPolicyRule,
WeChatConnectMode,
WebSearchEmulationConfig,
WebSearchProviderConfig,
WebSearchTestResult,
} from "@/api/admin/settings";
import type {
AdminGroup,
LoginAgreementDocument,
NotifyEmailEntry,
Proxy,
} from "@/types";
import type { ProviderInstance } from "@/types/payment";
import AppLayout from "@/components/layout/AppLayout.vue";
import Icon from "@/components/icons/Icon.vue";
import Select from "@/components/common/Select.vue";
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
import PaymentProviderList from "@/components/payment/PaymentProviderList.vue";
import PaymentProviderDialog from "@/components/payment/PaymentProviderDialog.vue";
import GroupBadge from "@/components/common/GroupBadge.vue";
import GroupOptionItem from "@/components/common/GroupOptionItem.vue";
import Toggle from "@/components/common/Toggle.vue";
import ProxySelector from "@/components/common/ProxySelector.vue";
import ImageUpload from "@/components/common/ImageUpload.vue";
import BackupSettings from "@/views/admin/BackupView.vue";
import { useClipboard } from "@/composables/useClipboard";
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
import { useAppStore } from "@/stores";
import { useAdminSettingsStore } from "@/stores/adminSettings";
import { normalizeVisibleMethod } from "@/components/payment/paymentFlow";
import {
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
parseRegistrationEmailSuffixWhitelistInput,
} from "@/utils/registrationEmailPolicy";
const { t, locale } = useI18n();
const appStore = useAppStore();
const adminSettingsStore = useAdminSettingsStore();
const isZhLocale = computed(() => locale.value.startsWith("zh"));
function localText(zh: string, en: string): string {
return isZhLocale.value ? zh : en;
}
const paymentGuideHref = computed(() =>
locale.value.startsWith("zh")
? "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md"
: "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md",
);
const paymentMethodsHref = computed(() =>
locale.value.startsWith("zh")
? "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#支持的支付方式"
: "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods",
);
type SettingsTab =
| "general"
| "agreement"
| "features"
| "security"
| "users"
| "gateway"
| "payment"
| "email"
| "backup";
const activeTab = ref<SettingsTab>("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
{ key: "agreement" as SettingsTab, icon: "document" as const },
{ key: "features" as SettingsTab, icon: "bolt" 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: "payment" as SettingsTab, icon: "creditCard" as const },
{ key: "email" as SettingsTab, icon: "mail" as const },
{ key: "backup" as SettingsTab, icon: "database" as const },
];
const settingsTabKeyboardActions = {
ArrowLeft: -1,
ArrowUp: -1,
ArrowRight: 1,
ArrowDown: 1,
Home: "first",
End: "last",
} as const;
function selectSettingsTab(tab: SettingsTab): void {
activeTab.value = tab;
}
function focusSettingsTab(tab: SettingsTab): void {
window.requestAnimationFrame(() => {
document.getElementById(`settings-tab-${tab}`)?.focus();
});
}
function handleSettingsTabKeydown(event: KeyboardEvent, tab: SettingsTab): void {
const action =
settingsTabKeyboardActions[
event.key as keyof typeof settingsTabKeyboardActions
];
if (action === undefined) {
return;
}
event.preventDefault();
const currentIndex = settingsTabs.findIndex((item) => item.key === tab);
let nextIndex = currentIndex < 0 ? 0 : currentIndex;
if (action === "first") {
nextIndex = 0;
} else if (action === "last") {
nextIndex = settingsTabs.length - 1;
} else {
nextIndex =
(nextIndex + action + settingsTabs.length) % settingsTabs.length;
}
const nextTab = settingsTabs[nextIndex]?.key;
if (!nextTab) {
return;
}
selectSettingsTab(nextTab);
focusSettingsTab(nextTab);
}
const { copyToClipboard } = useClipboard();
const loading = ref(true);
const loadFailed = ref(false);
const saving = ref(false);
const testingSmtp = ref(false);
const sendingTestEmail = ref(false);
const smtpPasswordManuallyEdited = ref(false);
const testEmailAddress = ref("");
const registrationEmailSuffixWhitelistTags = ref<string[]>([]);
const registrationEmailSuffixWhitelistDraft = ref("");
const tablePageSizeOptionsInput = ref("10, 20, 50, 100");
// Admin API Key 状态
const adminApiKeyLoading = ref(true);
const adminApiKeyExists = ref(false);
const adminApiKeyMasked = ref("");
const adminApiKeyOperating = ref(false);
const newAdminApiKey = ref("");
const subscriptionGroups = ref<AdminGroup[]>([]);
// Overload Cooldown (529) 状态
const overloadCooldownLoading = ref(true);
const overloadCooldownSaving = ref(false);
const overloadCooldownForm = reactive({
enabled: true,
cooldown_minutes: 10,
});
// Rate Limit Cooldown (429) 状态
const rateLimit429CooldownLoading = ref(true);
const rateLimit429CooldownSaving = ref(false);
const rateLimit429CooldownForm = reactive({
enabled: true,
cooldown_seconds: 5,
});
// Stream Timeout 状态
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,
});
// Rectifier 状态
const rectifierLoading = ref(true);
const rectifierSaving = ref(false);
const rectifierForm = reactive({
enabled: true,
thinking_signature_enabled: true,
thinking_budget_enabled: true,
apikey_signature_enabled: false,
apikey_signature_patterns: [] as string[],
});
// Beta Policy 状态
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;
model_whitelist?: string[];
fallback_action?: "pass" | "filter" | "block";
fallback_error_message?: string;
}>,
});
// OpenAI Fast/Flex Policy 状态
const openaiFastPolicyForm = reactive({
rules: [] as OpenAIFastPolicyRule[],
});
// 标记 openai_fast_policy_settings 是否已成功从后端加载,
// 避免后端 GET 出错或字段缺失时,保存把默认规则覆盖成空数组。
const openaiFastPolicyLoaded = ref(false);
const tablePageSizeMin = 5;
const tablePageSizeMax = 1000;
const tablePageSizeDefault = 20;
function defaultLoginAgreementDocuments(): LoginAgreementDocument[] {
return [
{
id: "terms",
title: "服务条款",
content_md: "",
},
{
id: "usage-policy",
title: "使用政策",
content_md: "",
},
{
id: "supported-regions",
title: "支持的国家和地区",
content_md: "",
},
{
id: "service-specific-terms",
title: "服务特定条款",
content_md: "",
},
];
}
function normalizeLoginAgreementDocumentId(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/[-_]{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function loginAgreementRoutePath(
doc: LoginAgreementDocument,
index: number,
): string {
const id =
normalizeLoginAgreementDocumentId(doc.id || doc.title) || `doc-${index + 1}`;
return `/legal/${id}`;
}
interface DefaultSubscriptionGroupOption {
value: number;
label: string;
description: string | null;
platform: AdminGroup["platform"];
subscriptionType: AdminGroup["subscription_type"];
rate: number;
[key: string]: unknown;
}
type SettingsForm = Omit<
SystemSettings,
| "wechat_connect_open_enabled"
| "wechat_connect_mp_enabled"
| "wechat_connect_mobile_enabled"
> & {
smtp_password: string;
turnstile_secret_key: string;
linuxdo_connect_client_secret: string;
dingtalk_connect_client_secret: string;
wechat_connect_app_secret: string;
wechat_connect_open_app_secret: string;
wechat_connect_mp_app_secret: string;
wechat_connect_mobile_app_secret: string;
wechat_connect_open_enabled: boolean;
wechat_connect_mp_enabled: boolean;
wechat_connect_mobile_enabled: boolean;
oidc_connect_client_secret: string;
github_oauth_client_secret: string;
google_oauth_client_secret: string;
force_email_on_third_party_signup: boolean;
openai_advanced_scheduler_enabled: boolean;
};
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,
login_agreement_enabled: false,
login_agreement_mode: "modal",
login_agreement_updated_at: "2026-03-31",
login_agreement_documents: defaultLoginAgreementDocuments(),
default_balance: 0,
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
affiliate_rebate_duration_days: 0,
affiliate_rebate_per_invitee_cap: 0,
default_concurrency: 1,
default_subscriptions: [],
force_email_on_third_party_signup: false,
default_user_rpm_limit: 0,
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,
payment_enabled: false,
risk_control_enabled: false,
payment_min_amount: 1,
payment_max_amount: 10000,
payment_daily_limit: 50000,
payment_max_pending_orders: 3,
payment_order_timeout_minutes: 30,
payment_balance_disabled: false,
payment_balance_recharge_multiplier: 1,
payment_recharge_fee_rate: 0,
payment_enabled_types: [],
payment_help_image_url: "",
payment_help_text: "",
payment_product_name_prefix: "",
payment_product_name_suffix: "",
payment_load_balance_strategy: "round-robin",
payment_cancel_rate_limit_enabled: false,
payment_cancel_rate_limit_max: 10,
payment_cancel_rate_limit_window: 1,
payment_cancel_rate_limit_unit: "day",
payment_cancel_rate_limit_window_mode: "rolling",
payment_alipay_force_qrcode: false,
table_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [] as Array<{
id: string;
label: string;
icon_svg: string;
url: string;
visibility: "user" | "admin";
sort_order: number;
}>,
custom_endpoints: [] as Array<{
name: string;
endpoint: string;
description: string;
}>,
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,
// Cloudflare Turnstile
turnstile_enabled: false,
turnstile_site_key: "",
turnstile_secret_key: "",
turnstile_secret_key_configured: false,
// LinuxDo Connect OAuth 登录
linuxdo_connect_enabled: false,
linuxdo_connect_client_id: "",
linuxdo_connect_client_secret: "",
linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: "",
// DingTalk Connect OAuth 登录
dingtalk_connect_enabled: false,
dingtalk_connect_client_id: "",
dingtalk_connect_client_secret: "",
dingtalk_connect_client_secret_configured: false,
dingtalk_connect_redirect_url: "",
dingtalk_connect_corp_restriction_policy: "none",
dingtalk_connect_internal_corp_id: "",
dingtalk_connect_bypass_registration: false,
dingtalk_connect_sync_corp_email: false,
dingtalk_connect_sync_display_name: false,
dingtalk_connect_sync_dept: false,
dingtalk_connect_sync_corp_email_attr_key: "dingtalk_email",
dingtalk_connect_sync_display_name_attr_key: "dingtalk_name",
dingtalk_connect_sync_dept_attr_key: "dingtalk_department",
dingtalk_connect_sync_corp_email_attr_name: "钉钉企业邮箱",
dingtalk_connect_sync_display_name_attr_name: "钉钉姓名",
dingtalk_connect_sync_dept_attr_name: "钉钉部门",
wechat_connect_enabled: false,
wechat_connect_app_id: "",
wechat_connect_app_secret: "",
wechat_connect_app_secret_configured: false,
wechat_connect_open_app_id: "",
wechat_connect_open_app_secret: "",
wechat_connect_open_app_secret_configured: false,
wechat_connect_mp_app_id: "",
wechat_connect_mp_app_secret: "",
wechat_connect_mp_app_secret_configured: false,
wechat_connect_mobile_app_id: "",
wechat_connect_mobile_app_secret: "",
wechat_connect_mobile_app_secret_configured: false,
wechat_connect_open_enabled: false,
wechat_connect_mp_enabled: false,
wechat_connect_mobile_enabled: false,
wechat_connect_mode: "open",
wechat_connect_scopes: "snsapi_login",
wechat_connect_redirect_url: "",
wechat_connect_frontend_redirect_url: "/auth/wechat/callback",
// Generic OIDC OAuth 登录
oidc_connect_enabled: false,
oidc_connect_provider_name: "OIDC",
oidc_connect_client_id: "",
oidc_connect_client_secret: "",
oidc_connect_client_secret_configured: false,
oidc_connect_issuer_url: "",
oidc_connect_discovery_url: "",
oidc_connect_authorize_url: "",
oidc_connect_token_url: "",
oidc_connect_userinfo_url: "",
oidc_connect_jwks_url: "",
oidc_connect_scopes: "openid email profile",
oidc_connect_redirect_url: "",
oidc_connect_frontend_redirect_url: "/auth/oidc/callback",
oidc_connect_token_auth_method: "client_secret_post",
oidc_connect_use_pkce: false,
oidc_connect_validate_id_token: false,
oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
oidc_connect_clock_skew_seconds: 120,
oidc_connect_require_email_verified: false,
oidc_connect_userinfo_email_path: "",
oidc_connect_userinfo_id_path: "",
oidc_connect_userinfo_username_path: "",
// GitHub / Google 邮箱快捷登录
github_oauth_enabled: false,
github_oauth_client_id: "",
github_oauth_client_secret: "",
github_oauth_client_secret_configured: false,
github_oauth_redirect_url: "",
github_oauth_frontend_redirect_url: "/auth/oauth/callback",
google_oauth_enabled: false,
google_oauth_client_id: "",
google_oauth_client_secret: "",
google_oauth_client_secret_configured: false,
google_oauth_redirect_url: "",
google_oauth_frontend_redirect_url: "/auth/oauth/callback",
// Model fallback
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",
// Identity patch (Claude -> Gemini)
enable_identity_patch: true,
identity_patch_prompt: "",
// Ops monitoring (vNext)
ops_monitoring_enabled: true,
ops_realtime_monitoring_enabled: true,
ops_query_mode_default: "auto",
ops_metrics_interval_seconds: 60,
// Claude Code version check
min_claude_code_version: "",
max_claude_code_version: "",
// 分组隔离
allow_ungrouped_key_scheduling: false,
openai_advanced_scheduler_enabled: false,
// Gateway forwarding behavior
enable_fingerprint_unification: true,
enable_metadata_passthrough: false,
enable_cch_signing: false,
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
antigravity_user_agent_version: "",
// Balance & quota notification
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: "",
account_quota_notify_enabled: false,
account_quota_notify_emails: [] as NotifyEmailEntry[],
// Channel Monitor feature switch
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
// Available Channels feature switch
available_channels_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
});
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
buildAuthSourceDefaultsState({}),
);
const authSourceDefaultsMeta = computed(() => [
{
source: "email" as AuthSourceType,
title: t("admin.settings.authSourceDefaults.sources.email.title"),
description: t("admin.settings.authSourceDefaults.sources.email.description"),
},
{
source: "linuxdo" as AuthSourceType,
title: t("admin.settings.authSourceDefaults.sources.linuxdo.title"),
description: t("admin.settings.authSourceDefaults.sources.linuxdo.description"),
},
{
source: "oidc" as AuthSourceType,
title: t("admin.settings.authSourceDefaults.sources.oidc.title"),
description: t("admin.settings.authSourceDefaults.sources.oidc.description"),
},
{
source: "wechat" as AuthSourceType,
title: t("admin.settings.authSourceDefaults.sources.wechat.title"),
description: t("admin.settings.authSourceDefaults.sources.wechat.description"),
},
{
source: "github" as AuthSourceType,
title: "GitHub",
description: localText(
"通过 GitHub 已验证邮箱首次注册或首次绑定时应用。",
"Applied on first signup or first bind through a verified GitHub email.",
),
},
{
source: "google" as AuthSourceType,
title: "Google",
description: localText(
"通过 Google 已验证邮箱首次注册或首次绑定时应用。",
"Applied on first signup or first bind through a verified Google email.",
),
},
{
source: "dingtalk" as AuthSourceType,
title: "钉钉",
description: localText(
"通过钉钉首次注册或首次绑定时应用。",
"Applied on first signup or first bind through DingTalk.",
),
},
]);
// Proxies for web search emulation ProxySelector
const webSearchProxies = ref<Proxy[]>([]);
// Web Search Emulation config (loaded/saved separately)
const DEFAULT_WEB_SEARCH_QUOTA_LIMIT = 1000;
const webSearchConfig = reactive<WebSearchEmulationConfig>({
enabled: false,
providers: [],
});
const expandedProviders = reactive<Record<number, boolean>>({});
const apiKeyVisible = reactive<Record<number, boolean>>({});
const wsTestQuery = ref("");
const wsTestLoading = ref(false);
const wsTestResult = ref<WebSearchTestResult | null>(null);
const wsTestDialogOpen = ref(false);
function openTestDialog() {
wsTestResult.value = null;
wsTestDialogOpen.value = true;
}
function toggleProviderExpand(idx: number) {
expandedProviders[idx] = !expandedProviders[idx];
}
function removeWebSearchProvider(idx: number) {
webSearchConfig.providers.splice(idx, 1);
// Re-index expandedProviders and apiKeyVisible after removal
const newExpanded: Record<number, boolean> = {};
const newVisible: Record<number, boolean> = {};
for (let i = 0; i < webSearchConfig.providers.length; i++) {
const oldIdx = i >= idx ? i + 1 : i;
newExpanded[i] = expandedProviders[oldIdx] ?? false;
newVisible[i] = apiKeyVisible[oldIdx] ?? false;
}
Object.keys(expandedProviders).forEach(
(k) => delete expandedProviders[Number(k)],
);
Object.keys(apiKeyVisible).forEach((k) => delete apiKeyVisible[Number(k)]);
Object.assign(expandedProviders, newExpanded);
Object.assign(apiKeyVisible, newVisible);
}
function addWebSearchProvider() {
const idx = webSearchConfig.providers.length;
webSearchConfig.providers.push({
type: "brave",
api_key: "",
api_key_configured: false,
quota_limit: DEFAULT_WEB_SEARCH_QUOTA_LIMIT,
subscribed_at: null,
proxy_id: null,
expires_at: null,
} as WebSearchProviderConfig);
expandedProviders[idx] = true;
}
function formatSubscribedAt(ts: number | null): string {
if (!ts) return "";
// Use UTC to avoid timezone drift on repeated edits
const d = new Date(ts * 1000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseSubscribedAt(dateStr: string): number | null {
if (!dateStr) return null;
// Parse as UTC to match formatSubscribedAt
return Math.floor(new Date(dateStr + "T00:00:00Z").getTime() / 1000);
}
function quotaPercentage(provider: WebSearchProviderConfig): number {
if (!provider.quota_limit || provider.quota_limit <= 0) return 0;
return ((provider.quota_used ?? 0) / provider.quota_limit) * 100;
}
async function resetWebSearchUsage(idx: number) {
const provider = webSearchConfig.providers[idx];
if (!provider) return;
if (!confirm(t("admin.settings.webSearchEmulation.resetUsageConfirm")))
return;
try {
await adminAPI.settings.resetWebSearchUsage({
provider_type: provider.type,
});
provider.quota_used = 0;
appStore.showSuccess(
t("admin.settings.webSearchEmulation.resetUsageSuccess"),
);
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
}
async function copyApiKey(idx: number) {
const key = webSearchConfig.providers[idx]?.api_key;
if (!key) {
appStore.showError(
t("admin.settings.webSearchEmulation.apiKeyPlaceholder"),
);
return;
}
try {
await navigator.clipboard.writeText(key);
appStore.showSuccess(t("admin.settings.webSearchEmulation.copied"));
} catch {
appStore.showError(t("common.error"));
}
}
async function testWebSearchProvider() {
wsTestLoading.value = true;
wsTestResult.value = null;
try {
const query =
wsTestQuery.value.trim() ||
t("admin.settings.webSearchEmulation.testDefaultQuery");
wsTestResult.value = await adminAPI.settings.testWebSearchEmulation(query);
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
wsTestLoading.value = false;
}
}
async function loadWebSearchConfig() {
try {
const [resp, proxiesResp] = await Promise.all([
adminAPI.settings.getWebSearchEmulationConfig(),
adminAPI.proxies.list().catch(() => ({ items: [] as Proxy[] })),
]);
if (resp) {
webSearchConfig.enabled = resp.enabled || false;
webSearchConfig.providers = resp.providers || [];
}
webSearchProxies.value = proxiesResp.items || [];
} catch (err: unknown) {
// 404 is expected when config hasn't been created yet; show error for other failures
const status = (err as { status?: number })?.status;
if (status !== 404 && status !== undefined) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
}
}
async function saveWebSearchConfig(): Promise<boolean> {
try {
for (const p of webSearchConfig.providers) {
const raw = p.quota_limit;
if (raw != null && Number(raw) !== 0 && Number(raw) < 1) {
appStore.showError(
t("admin.settings.webSearchEmulation.quotaLimitMustBePositive"),
);
return false;
}
}
const providers = webSearchConfig.providers.map(
(p: WebSearchProviderConfig) => ({
...p,
quota_limit: Number(p.quota_limit) > 0 ? Number(p.quota_limit) : null,
}),
);
await adminAPI.settings.updateWebSearchEmulationConfig({
enabled: webSearchConfig.enabled,
providers,
});
return true;
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
return 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);
}
}
// Quota notify email helpers
const addQuotaNotifyEmail = () => {
if (!form.account_quota_notify_emails) {
form.account_quota_notify_emails = [];
}
form.account_quota_notify_emails.push({
email: "",
disabled: false,
verified: true,
});
};
const currentOrigin =
typeof window !== "undefined" ? window.location.origin : "";
// LinuxDo OAuth redirect URL suggestion
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"),
);
}
type EmailOAuthProvider = "github" | "google";
const githubOAuthRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
window.location.origin ||
`${window.location.protocol}//${window.location.host}`;
return `${origin}/api/v1/auth/oauth/github/callback`;
});
const googleOAuthRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
window.location.origin ||
`${window.location.protocol}//${window.location.host}`;
return `${origin}/api/v1/auth/oauth/google/callback`;
});
async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) {
const url =
provider === "github"
? githubOAuthRedirectUrlSuggestion.value
: googleOAuthRedirectUrlSuggestion.value;
if (!url) return;
if (provider === "github") {
form.github_oauth_redirect_url = url;
} else {
form.google_oauth_redirect_url = url;
}
await copyToClipboard(
url,
localText("回调地址已写入并复制。", "Callback URL set and copied."),
);
}
const wechatRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
window.location.origin ||
`${window.location.protocol}//${window.location.host}`;
return `${origin}/api/v1/auth/oauth/wechat/callback`;
});
function syncWeChatConnectMode(preferredMode?: WeChatConnectMode) {
if (form.wechat_connect_mp_enabled && form.wechat_connect_mobile_enabled) {
if (preferredMode === "mobile") {
form.wechat_connect_mp_enabled = false;
} else {
form.wechat_connect_mobile_enabled = false;
}
}
const capabilities = resolveWeChatConnectModeCapabilities(
form.wechat_connect_open_enabled,
form.wechat_connect_mp_enabled,
form.wechat_connect_mobile_enabled,
form.wechat_connect_mode,
);
form.wechat_connect_open_enabled = capabilities.openEnabled;
form.wechat_connect_mp_enabled = capabilities.mpEnabled;
form.wechat_connect_mobile_enabled = capabilities.mobileEnabled;
form.wechat_connect_mode = deriveWeChatConnectStoredMode(
capabilities.openEnabled,
capabilities.mpEnabled,
capabilities.mobileEnabled,
form.wechat_connect_mode,
);
form.wechat_connect_scopes = defaultWeChatConnectScopesForMode(
form.wechat_connect_mode,
);
}
function handleWeChatOpenEnabledChange(value: boolean) {
form.wechat_connect_open_enabled = value;
syncWeChatConnectMode(value ? "open" : undefined);
}
function handleWeChatMPEnabledChange(value: boolean) {
form.wechat_connect_mp_enabled = value;
if (value) {
form.wechat_connect_mobile_enabled = false;
}
syncWeChatConnectMode(value ? "mp" : undefined);
}
function handleWeChatMobileEnabledChange(value: boolean) {
form.wechat_connect_mobile_enabled = value;
if (value) {
form.wechat_connect_mp_enabled = false;
}
syncWeChatConnectMode(value ? "mobile" : undefined);
}
async function setAndCopyWeChatRedirectUrl() {
const url = wechatRedirectUrlSuggestion.value;
if (!url) return;
form.wechat_connect_redirect_url = url;
await copyToClipboard(
url,
t("admin.settings.wechatConnect.redirectUrlSetAndCopied"),
);
}
const oidcRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
window.location.origin ||
`${window.location.protocol}//${window.location.host}`;
return `${origin}/api/v1/auth/oauth/oidc/callback`;
});
async function setAndCopyOIDCRedirectUrl() {
const url = oidcRedirectUrlSuggestion.value;
if (!url) return;
form.oidc_connect_redirect_url = url;
await copyToClipboard(url, t("admin.settings.oidc.redirectUrlSetAndCopied"));
}
// Custom menu item management
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);
// Re-index sort_order
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;
// Re-index sort_order
items.forEach((item, i) => {
item.sort_order = i;
});
}
// Custom endpoint management
function addEndpoint() {
form.custom_endpoints.push({ name: "", endpoint: "", description: "" });
}
function removeEndpoint(index: number) {
form.custom_endpoints.splice(index, 1);
}
function addLoginAgreementDocument() {
form.login_agreement_documents.push({
id: `custom-${Date.now().toString(36)}`,
title: "",
content_md: "",
});
}
function removeLoginAgreementDocument(index: number) {
form.login_agreement_documents.splice(index, 1);
}
function normalizeLoginAgreementDocumentsForSave(): LoginAgreementDocument[] {
return form.login_agreement_documents
.map((doc, index) => ({
id:
normalizeLoginAgreementDocumentId(doc.id || doc.title) ||
`doc-${index + 1}`,
title: doc.title.trim(),
content_md: doc.content_md.trim(),
}))
.filter((doc) => doc.title || doc.content_md);
}
function findDuplicateLoginAgreementDocumentId(
documents: LoginAgreementDocument[],
): string | null {
const seen = new Set<string>();
for (const doc of documents) {
if (seen.has(doc.id)) {
return doc.id;
}
seen.add(doc.id);
}
return null;
}
function formatTablePageSizeOptions(options: number[]): string {
return options.join(", ");
}
function parseTablePageSizeOptionsInput(raw: string): number[] | null {
const tokens = raw
.split(",")
.map((token) => token.trim())
.filter((token) => token.length > 0);
if (tokens.length === 0) {
return null;
}
const parsed = tokens.map((token) => Number(token));
if (parsed.some((value) => !Number.isInteger(value))) {
return null;
}
const deduped = Array.from(new Set(parsed)).sort((a, b) => a - b);
if (
deduped.some(
(value) => value < tablePageSizeMin || value > tablePageSizeMax,
)
) {
return null;
}
return deduped;
}
async function loadSettings() {
loading.value = true;
loadFailed.value = false;
try {
const settings = await adminAPI.settings.getSettings();
settings.payment_load_balance_strategy =
settings.payment_load_balance_strategy || "round-robin";
// Only assign non-null values from backend (null means unconfigured, keep defaults)
for (const [key, value] of Object.entries(settings)) {
if (value !== null && value !== undefined) {
(form as Record<string, unknown>)[key] = value;
}
}
form.login_agreement_mode =
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.login_agreement_updated_at =
settings.login_agreement_updated_at || "2026-03-31";
form.login_agreement_documents =
Array.isArray(settings.login_agreement_documents) &&
settings.login_agreement_documents.length > 0
? settings.login_agreement_documents.map((doc) => ({
id: doc.id || "",
title: doc.title || "",
content_md: doc.content_md || "",
}))
: defaultLoginAgreementDocuments();
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
form.backend_mode_enabled = settings.backend_mode_enabled;
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
settings.default_subscriptions,
);
registrationEmailSuffixWhitelistTags.value =
normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist,
);
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
Array.isArray(settings.table_page_size_options)
? settings.table_page_size_options
: [10, 20, 50, 100],
);
registrationEmailSuffixWhitelistDraft.value = "";
form.smtp_password = "";
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.dingtalk_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
form.wechat_connect_open_app_secret = "";
form.wechat_connect_mp_app_secret = "";
form.wechat_connect_mobile_app_secret = "";
const wechatCapabilities = resolveWeChatConnectModeCapabilities(
settings.wechat_connect_open_enabled,
settings.wechat_connect_mp_enabled,
settings.wechat_connect_mobile_enabled,
settings.wechat_connect_mode,
);
form.wechat_connect_open_enabled = wechatCapabilities.openEnabled;
form.wechat_connect_mp_enabled = wechatCapabilities.mpEnabled;
form.wechat_connect_mobile_enabled = wechatCapabilities.mobileEnabled;
form.wechat_connect_mode = deriveWeChatConnectStoredMode(
wechatCapabilities.openEnabled,
wechatCapabilities.mpEnabled,
wechatCapabilities.mobileEnabled,
settings.wechat_connect_mode,
);
const legacyWeChatAppID = String(settings.wechat_connect_app_id || "").trim();
const legacyWeChatSecretConfigured = Boolean(
settings.wechat_connect_app_secret_configured,
);
if (!form.wechat_connect_open_app_id && wechatCapabilities.openEnabled) {
form.wechat_connect_open_app_id = legacyWeChatAppID;
}
if (!form.wechat_connect_mp_app_id && wechatCapabilities.mpEnabled) {
form.wechat_connect_mp_app_id = legacyWeChatAppID;
}
if (!form.wechat_connect_mobile_app_id && wechatCapabilities.mobileEnabled) {
form.wechat_connect_mobile_app_id = legacyWeChatAppID;
}
if (
!form.wechat_connect_open_app_secret_configured &&
wechatCapabilities.openEnabled
) {
form.wechat_connect_open_app_secret_configured =
legacyWeChatSecretConfigured;
}
if (
!form.wechat_connect_mp_app_secret_configured &&
wechatCapabilities.mpEnabled
) {
form.wechat_connect_mp_app_secret_configured = legacyWeChatSecretConfigured;
}
if (
!form.wechat_connect_mobile_app_secret_configured &&
wechatCapabilities.mobileEnabled
) {
form.wechat_connect_mobile_app_secret_configured =
legacyWeChatSecretConfigured;
}
form.wechat_connect_scopes = defaultWeChatConnectScopesForMode(
form.wechat_connect_mode,
);
form.oidc_connect_client_secret = "";
// Load OpenAI fast/flex policy rules from bulk settings.
// 仅当 payload 真的包含该字段时填充并标记为已加载;否则保持表单空值,
// 让 saveSettings 在未加载时跳过该字段,防止覆盖后端默认规则。
if (
settings.openai_fast_policy_settings &&
Array.isArray(settings.openai_fast_policy_settings.rules)
) {
openaiFastPolicyForm.rules =
settings.openai_fast_policy_settings.rules.map((rule) => ({
...rule,
model_whitelist: rule.model_whitelist
? [...rule.model_whitelist]
: [],
}));
openaiFastPolicyLoaded.value = true;
}
// Load web search emulation config separately
await loadWebSearchConfig();
} catch (error: unknown) {
loadFailed.value = true;
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.failedToLoad")),
);
} 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: unknown) {
subscriptionGroups.value = [];
}
}
function findNextAvailableSubscriptionGroup(
existingGroupIDs: number[],
): AdminGroup | undefined {
const existing = new Set(existingGroupIDs);
return subscriptionGroups.value.find((group) => !existing.has(group.id));
}
function addDefaultSubscription() {
if (subscriptionGroups.value.length === 0) return;
const candidate = findNextAvailableSubscriptionGroup(
form.default_subscriptions.map((item) => item.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);
}
function addAuthSourceDefaultSubscription(source: AuthSourceType) {
if (subscriptionGroups.value.length === 0) return;
const candidate = findNextAvailableSubscriptionGroup(
authSourceDefaults[source].subscriptions.map((item) => item.group_id),
);
if (!candidate) return;
authSourceDefaults[source].subscriptions.push({
group_id: candidate.id,
validity_days: 30,
});
}
function removeAuthSourceDefaultSubscription(
source: AuthSourceType,
index: number,
) {
authSourceDefaults[source].subscriptions.splice(index, 1);
}
function findDuplicateDefaultSubscription(
subscriptions: DefaultSubscriptionSetting[],
): DefaultSubscriptionSetting | undefined {
const seenGroupIDs = new Set<number>();
return subscriptions.find((item) => {
if (seenGroupIDs.has(item.group_id)) {
return true;
}
seenGroupIDs.add(item.group_id);
return false;
});
}
async function saveSettings() {
saving.value = true;
try {
const normalizedTableDefaultPageSize = Math.floor(
Number(form.table_default_page_size),
);
if (
!Number.isInteger(normalizedTableDefaultPageSize) ||
normalizedTableDefaultPageSize < tablePageSizeMin ||
normalizedTableDefaultPageSize > tablePageSizeMax
) {
appStore.showError(
t("admin.settings.site.tableDefaultPageSizeRangeError", {
min: tablePageSizeMin,
max: tablePageSizeMax,
}),
);
return;
}
const normalizedTablePageSizeOptions = parseTablePageSizeOptionsInput(
tablePageSizeOptionsInput.value,
);
if (!normalizedTablePageSizeOptions) {
appStore.showError(
t("admin.settings.site.tablePageSizeOptionsFormatError", {
min: tablePageSizeMin,
max: tablePageSizeMax,
}),
);
return;
}
form.table_default_page_size = normalizedTableDefaultPageSize;
form.table_page_size_options = normalizedTablePageSizeOptions;
const normalizedLoginAgreementDocuments =
normalizeLoginAgreementDocumentsForSave();
if (form.login_agreement_enabled && normalizedLoginAgreementDocuments.length === 0) {
appStore.showError(
localText(
"启用登录条款确认时,至少需要保留一份文档。",
"At least one document is required when login agreement is enabled.",
),
);
return;
}
const emptyTitleDocument = normalizedLoginAgreementDocuments.find(
(doc) => !doc.title,
);
if (emptyTitleDocument) {
appStore.showError(
localText(
"登录条款文档名称不能为空。",
"Login agreement document title cannot be empty.",
),
);
return;
}
const duplicateLoginAgreementDocumentId =
findDuplicateLoginAgreementDocumentId(normalizedLoginAgreementDocuments);
if (duplicateLoginAgreementDocumentId) {
appStore.showError(
localText(
`登录条款文档路由不能重复:/legal/${duplicateLoginAgreementDocumentId}`,
`Login agreement document routes cannot be duplicated: /legal/${duplicateLoginAgreementDocumentId}`,
),
);
return;
}
form.login_agreement_mode =
form.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.login_agreement_documents = normalizedLoginAgreementDocuments;
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
form.default_subscriptions,
);
const duplicateDefaultSubscription = findDuplicateDefaultSubscription(
normalizedDefaultSubscriptions,
);
if (duplicateDefaultSubscription) {
appStore.showError(
t("admin.settings.defaults.defaultSubscriptionsDuplicate", {
groupId: duplicateDefaultSubscription.group_id,
}),
);
return;
}
for (const authSource of authSourceDefaultsMeta.value) {
authSourceDefaults[authSource.source].subscriptions =
normalizeDefaultSubscriptionSettings(
authSourceDefaults[authSource.source].subscriptions,
);
const duplicate = findDuplicateDefaultSubscription(
authSourceDefaults[authSource.source].subscriptions,
);
if (duplicate) {
appStore.showError(
`${authSource.title}: ${t(
"admin.settings.defaults.defaultSubscriptionsDuplicate",
{
groupId: duplicate.group_id,
},
)}`,
);
return;
}
}
if (form.wechat_connect_mp_enabled && form.wechat_connect_mobile_enabled) {
appStore.showError(
localText(
"公众号和移动应用不能同时启用。",
"Official Account and Mobile App cannot be enabled at the same time.",
),
);
return;
}
// Validate URL fields — novalidate disables browser-native checks, so we validate here
const isValidHttpUrl = (url: string): boolean => {
if (!url) return true;
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
};
// Optional URL fields: auto-clear invalid values so they don't cause backend 400 errors
if (!isValidHttpUrl(form.frontend_url)) form.frontend_url = "";
if (!isValidHttpUrl(form.doc_url)) form.doc_url = "";
syncWeChatConnectMode();
const wechatStoredMode = deriveWeChatConnectStoredMode(
form.wechat_connect_open_enabled,
form.wechat_connect_mp_enabled,
form.wechat_connect_mobile_enabled,
form.wechat_connect_mode,
);
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,
login_agreement_enabled: form.login_agreement_enabled,
login_agreement_mode: form.login_agreement_mode,
login_agreement_updated_at: form.login_agreement_updated_at,
login_agreement_documents: form.login_agreement_documents,
default_balance: form.default_balance,
affiliate_rebate_rate: Math.min(
100,
Math.max(0, Number(form.affiliate_rebate_rate) || 0),
),
affiliate_rebate_freeze_hours: Math.max(0, Math.min(720, Number(form.affiliate_rebate_freeze_hours) || 0)),
affiliate_rebate_duration_days: Math.max(0, Math.min(3650, Math.floor(Number(form.affiliate_rebate_duration_days) || 0))),
affiliate_rebate_per_invitee_cap: Math.max(0, Number(form.affiliate_rebate_per_invitee_cap) || 0),
default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
default_user_rpm_limit: form.default_user_rpm_limit,
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,
table_default_page_size: form.table_default_page_size,
table_page_size_options: form.table_page_size_options,
custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints,
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,
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,
dingtalk_connect_enabled: form.dingtalk_connect_enabled,
dingtalk_connect_client_id: form.dingtalk_connect_client_id,
dingtalk_connect_client_secret:
form.dingtalk_connect_client_secret || undefined,
dingtalk_connect_redirect_url: form.dingtalk_connect_redirect_url,
dingtalk_connect_corp_restriction_policy:
form.dingtalk_connect_corp_restriction_policy,
dingtalk_connect_internal_corp_id: form.dingtalk_connect_internal_corp_id,
dingtalk_connect_bypass_registration: form.dingtalk_connect_bypass_registration,
dingtalk_connect_sync_corp_email: form.dingtalk_connect_sync_corp_email,
dingtalk_connect_sync_display_name: form.dingtalk_connect_sync_display_name,
dingtalk_connect_sync_dept: form.dingtalk_connect_sync_dept,
dingtalk_connect_sync_corp_email_attr_key: form.dingtalk_connect_sync_corp_email_attr_key,
dingtalk_connect_sync_display_name_attr_key: form.dingtalk_connect_sync_display_name_attr_key,
dingtalk_connect_sync_dept_attr_key: form.dingtalk_connect_sync_dept_attr_key,
dingtalk_connect_sync_corp_email_attr_name: form.dingtalk_connect_sync_corp_email_attr_name,
dingtalk_connect_sync_display_name_attr_name: form.dingtalk_connect_sync_display_name_attr_name,
dingtalk_connect_sync_dept_attr_name: form.dingtalk_connect_sync_dept_attr_name,
wechat_connect_enabled: form.wechat_connect_enabled,
wechat_connect_app_id:
form.wechat_connect_open_app_id ||
form.wechat_connect_mp_app_id ||
form.wechat_connect_mobile_app_id ||
form.wechat_connect_app_id,
wechat_connect_app_secret: form.wechat_connect_app_secret || undefined,
wechat_connect_open_app_id: form.wechat_connect_open_app_id,
wechat_connect_open_app_secret:
form.wechat_connect_open_app_secret || undefined,
wechat_connect_mp_app_id: form.wechat_connect_mp_app_id,
wechat_connect_mp_app_secret:
form.wechat_connect_mp_app_secret || undefined,
wechat_connect_mobile_app_id: form.wechat_connect_mobile_app_id,
wechat_connect_mobile_app_secret:
form.wechat_connect_mobile_app_secret || undefined,
wechat_connect_open_enabled: form.wechat_connect_open_enabled,
wechat_connect_mp_enabled: form.wechat_connect_mp_enabled,
wechat_connect_mobile_enabled: form.wechat_connect_mobile_enabled,
wechat_connect_mode: wechatStoredMode,
wechat_connect_scopes:
defaultWeChatConnectScopesForMode(wechatStoredMode),
wechat_connect_redirect_url: form.wechat_connect_redirect_url,
wechat_connect_frontend_redirect_url:
form.wechat_connect_frontend_redirect_url,
oidc_connect_enabled: form.oidc_connect_enabled,
oidc_connect_provider_name: form.oidc_connect_provider_name,
oidc_connect_client_id: form.oidc_connect_client_id,
oidc_connect_client_secret: form.oidc_connect_client_secret || undefined,
oidc_connect_issuer_url: form.oidc_connect_issuer_url,
oidc_connect_discovery_url: form.oidc_connect_discovery_url,
oidc_connect_authorize_url: form.oidc_connect_authorize_url,
oidc_connect_token_url: form.oidc_connect_token_url,
oidc_connect_userinfo_url: form.oidc_connect_userinfo_url,
oidc_connect_jwks_url: form.oidc_connect_jwks_url,
oidc_connect_scopes: form.oidc_connect_scopes,
oidc_connect_redirect_url: form.oidc_connect_redirect_url,
oidc_connect_frontend_redirect_url:
form.oidc_connect_frontend_redirect_url,
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
oidc_connect_use_pkce: form.oidc_connect_use_pkce,
oidc_connect_validate_id_token: form.oidc_connect_validate_id_token,
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
oidc_connect_require_email_verified:
form.oidc_connect_require_email_verified,
oidc_connect_userinfo_email_path: form.oidc_connect_userinfo_email_path,
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
oidc_connect_userinfo_username_path:
form.oidc_connect_userinfo_username_path,
github_oauth_enabled: form.github_oauth_enabled,
github_oauth_client_id: form.github_oauth_client_id,
github_oauth_client_secret:
form.github_oauth_client_secret || undefined,
github_oauth_redirect_url: form.github_oauth_redirect_url,
github_oauth_frontend_redirect_url:
form.github_oauth_frontend_redirect_url,
google_oauth_enabled: form.google_oauth_enabled,
google_oauth_client_id: form.google_oauth_client_id,
google_oauth_client_secret:
form.google_oauth_client_secret || undefined,
google_oauth_redirect_url: form.google_oauth_redirect_url,
google_oauth_frontend_redirect_url:
form.google_oauth_frontend_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,
enable_fingerprint_unification: form.enable_fingerprint_unification,
enable_metadata_passthrough: form.enable_metadata_passthrough,
enable_cch_signing: form.enable_cch_signing,
enable_anthropic_cache_ttl_1h_injection:
form.enable_anthropic_cache_ttl_1h_injection,
rewrite_message_cache_control: form.rewrite_message_cache_control,
antigravity_user_agent_version:
form.antigravity_user_agent_version?.trim() || "",
// Payment configuration
payment_enabled: form.payment_enabled,
risk_control_enabled: form.risk_control_enabled,
payment_min_amount: Number(form.payment_min_amount) || 0,
payment_max_amount: Number(form.payment_max_amount) || 0,
payment_daily_limit: Number(form.payment_daily_limit) || 0,
payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0,
payment_order_timeout_minutes:
Number(form.payment_order_timeout_minutes) || 0,
payment_balance_disabled: form.payment_balance_disabled,
payment_balance_recharge_multiplier:
Number(form.payment_balance_recharge_multiplier) || 1,
payment_recharge_fee_rate: Number(form.payment_recharge_fee_rate) || 0,
payment_enabled_types: form.payment_enabled_types,
payment_load_balance_strategy: form.payment_load_balance_strategy,
payment_product_name_prefix: form.payment_product_name_prefix,
payment_product_name_suffix: form.payment_product_name_suffix,
payment_help_image_url: form.payment_help_image_url,
payment_help_text: form.payment_help_text,
payment_cancel_rate_limit_enabled: form.payment_cancel_rate_limit_enabled,
payment_cancel_rate_limit_max:
Number(form.payment_cancel_rate_limit_max) || 10,
payment_cancel_rate_limit_window:
Number(form.payment_cancel_rate_limit_window) || 1,
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode:
form.payment_cancel_rate_limit_window_mode,
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
// Balance & quota notification
balance_low_notify_enabled: form.balance_low_notify_enabled,
balance_low_notify_threshold:
Number(form.balance_low_notify_threshold) || 0,
balance_low_notify_recharge_url: (form.balance_low_notify_recharge_url =
form.balance_low_notify_recharge_url || currentOrigin),
account_quota_notify_enabled: form.account_quota_notify_enabled,
account_quota_notify_emails: (
form.account_quota_notify_emails || []
).filter((e) => e.email.trim() !== ""),
// Channel Monitor feature switch
channel_monitor_enabled: form.channel_monitor_enabled,
channel_monitor_default_interval_seconds:
Number(form.channel_monitor_default_interval_seconds) || 60,
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
};
// 仅当 openai_fast_policy_settings 已成功从后端加载时才回写,
// 否则省略整个字段,让后端保留既有规则(含默认值)。
if (openaiFastPolicyLoaded.value) {
payload.openai_fast_policy_settings = {
rules: openaiFastPolicyForm.rules.map((rule) => {
const whitelist = (rule.model_whitelist || [])
.map((p) => p.trim())
.filter((p) => p !== "");
const hasWhitelist = whitelist.length > 0;
return {
service_tier: rule.service_tier,
action: rule.action,
scope: rule.scope,
error_message:
rule.action === "block" ? rule.error_message : undefined,
model_whitelist: hasWhitelist ? whitelist : undefined,
fallback_action: hasWhitelist
? rule.fallback_action || "pass"
: undefined,
fallback_error_message:
hasWhitelist && rule.fallback_action === "block"
? rule.fallback_error_message
: undefined,
};
}),
};
}
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
const updated = await adminAPI.settings.updateSettings(payload);
for (const [key, value] of Object.entries(updated)) {
if (key === "openai_fast_policy_settings") continue;
if (value !== null && value !== undefined) {
(form as Record<string, unknown>)[key] = value;
}
}
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
registrationEmailSuffixWhitelistTags.value =
normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist,
);
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
Array.isArray(updated.table_page_size_options)
? updated.table_page_size_options
: [10, 20, 50, 100],
);
registrationEmailSuffixWhitelistDraft.value = "";
form.smtp_password = "";
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.dingtalk_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
form.wechat_connect_open_app_secret = "";
form.wechat_connect_mp_app_secret = "";
form.wechat_connect_mobile_app_secret = "";
const updatedWechatCapabilities = resolveWeChatConnectModeCapabilities(
updated.wechat_connect_open_enabled,
updated.wechat_connect_mp_enabled,
updated.wechat_connect_mobile_enabled,
updated.wechat_connect_mode,
);
form.wechat_connect_open_enabled = updatedWechatCapabilities.openEnabled;
form.wechat_connect_mp_enabled = updatedWechatCapabilities.mpEnabled;
form.wechat_connect_mobile_enabled =
updatedWechatCapabilities.mobileEnabled;
form.wechat_connect_mode = deriveWeChatConnectStoredMode(
updatedWechatCapabilities.openEnabled,
updatedWechatCapabilities.mpEnabled,
updatedWechatCapabilities.mobileEnabled,
updated.wechat_connect_mode,
);
form.wechat_connect_scopes = defaultWeChatConnectScopesForMode(
form.wechat_connect_mode,
);
form.oidc_connect_client_secret = "";
// Refresh OpenAI fast/flex policy from server response
if (
updated.openai_fast_policy_settings &&
Array.isArray(updated.openai_fast_policy_settings.rules)
) {
openaiFastPolicyForm.rules =
updated.openai_fast_policy_settings.rules.map((rule) => ({
...rule,
model_whitelist: rule.model_whitelist
? [...rule.model_whitelist]
: [],
}));
openaiFastPolicyLoaded.value = true;
}
// Save web search emulation config separately (errors handled internally)
const wsOk = await saveWebSearchConfig();
// Refresh cached settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true);
await adminSettingsStore.fetch(true);
if (wsOk) {
appStore.showSuccess(t("admin.settings.settingsSaved"));
}
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.failedToSave")),
);
} finally {
saving.value = false;
}
}
async function testSmtpConnection() {
testingSmtp.value = true;
try {
const smtpPasswordForTest = smtpPasswordManuallyEdited.value
? form.smtp_password
: "";
const result = await adminAPI.settings.testSmtpConnection({
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_username: form.smtp_username,
smtp_password: smtpPasswordForTest,
smtp_use_tls: form.smtp_use_tls,
});
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(
result.message || t("admin.settings.smtpConnectionSuccess"),
);
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.failedToTestSmtp")),
);
} finally {
testingSmtp.value = false;
}
}
async function sendTestEmail() {
if (!testEmailAddress.value) {
appStore.showError(t("admin.settings.testEmail.enterRecipientHint"));
return;
}
sendingTestEmail.value = true;
try {
const smtpPasswordForSend = smtpPasswordManuallyEdited.value
? form.smtp_password
: "";
const result = await adminAPI.settings.sendTestEmail({
email: testEmailAddress.value,
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_username: form.smtp_username,
smtp_password: smtpPasswordForSend,
smtp_from_email: form.smtp_from_email,
smtp_from_name: form.smtp_from_name,
smtp_use_tls: form.smtp_use_tls,
});
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t("admin.settings.testEmailSent"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.failedToSendTestEmail")),
);
} finally {
sendingTestEmail.value = false;
}
}
// Admin API Key 方法
async function loadAdminApiKey() {
adminApiKeyLoading.value = true;
try {
const status = await adminAPI.settings.getAdminApiKey();
adminApiKeyExists.value = status.exists;
adminApiKeyMasked.value = status.masked_key;
} catch (_error: unknown) {
// Silent fail - admin API key status is non-critical
} 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: unknown) {
appStore.showError(extractApiErrorMessage(error, 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: unknown) {
appStore.showError(extractApiErrorMessage(error, 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"));
});
}
// Overload Cooldown 方法
async function loadOverloadCooldownSettings() {
overloadCooldownLoading.value = true;
try {
const settings = await adminAPI.settings.getOverloadCooldownSettings();
Object.assign(overloadCooldownForm, settings);
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} 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: unknown) {
appStore.showError(
extractApiErrorMessage(
error,
t("admin.settings.overloadCooldown.saveFailed"),
),
);
} finally {
overloadCooldownSaving.value = false;
}
}
// Rate Limit Cooldown (429) 方法
async function loadRateLimit429CooldownSettings() {
rateLimit429CooldownLoading.value = true;
try {
const settings = await adminAPI.settings.getRateLimit429CooldownSettings();
Object.assign(rateLimit429CooldownForm, settings);
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
rateLimit429CooldownLoading.value = false;
}
}
async function saveRateLimit429CooldownSettings() {
rateLimit429CooldownSaving.value = true;
try {
const updated = await adminAPI.settings.updateRateLimit429CooldownSettings({
enabled: rateLimit429CooldownForm.enabled,
cooldown_seconds: rateLimit429CooldownForm.cooldown_seconds,
});
Object.assign(rateLimit429CooldownForm, updated);
appStore.showSuccess(t("admin.settings.rateLimit429Cooldown.saved"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(
error,
t("admin.settings.rateLimit429Cooldown.saveFailed"),
),
);
} finally {
rateLimit429CooldownSaving.value = false;
}
}
// Stream Timeout 方法
async function loadStreamTimeoutSettings() {
streamTimeoutLoading.value = true;
try {
const settings = await adminAPI.settings.getStreamTimeoutSettings();
Object.assign(streamTimeoutForm, settings);
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} 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: unknown) {
appStore.showError(
extractApiErrorMessage(
error,
t("admin.settings.streamTimeout.saveFailed"),
),
);
} finally {
streamTimeoutSaving.value = false;
}
}
// Rectifier 方法
async function loadRectifierSettings() {
rectifierLoading.value = true;
try {
const settings = await adminAPI.settings.getRectifierSettings();
Object.assign(rectifierForm, settings);
// 确保 patterns 是数组(旧数据可能为 null
if (!Array.isArray(rectifierForm.apikey_signature_patterns)) {
rectifierForm.apikey_signature_patterns = [];
}
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} 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,
apikey_signature_enabled: rectifierForm.apikey_signature_enabled,
apikey_signature_patterns: rectifierForm.apikey_signature_patterns.filter(
(p) => p.trim() !== "",
),
});
Object.assign(rectifierForm, updated);
if (!Array.isArray(rectifierForm.apikey_signature_patterns)) {
rectifierForm.apikey_signature_patterns = [];
}
appStore.showSuccess(t("admin.settings.rectifier.saved"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.rectifier.saveFailed")),
);
} 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") },
]);
// Beta Policy 方法
const betaDisplayNames: Record<string, string> = {
"fast-mode-2026-02-01": "Fast Mode",
"context-1m-2025-08-07": "Context 1M",
};
// 快捷预设:按 beta_token 定义预设方案
const betaPresets: Record<
string,
Array<{
label: string;
description: string;
action: "pass" | "filter" | "block";
model_whitelist: string[];
fallback_action: "pass" | "filter" | "block";
}>
> = {
"context-1m-2025-08-07": [
{
label: t("admin.settings.betaPolicy.presetOpusOnly"),
description: t("admin.settings.betaPolicy.presetOpusOnlyDesc"),
action: "pass",
model_whitelist: ["claude-opus-4-6"],
fallback_action: "filter",
},
],
};
// 常用模型模式(具体 ID + 通配符示例)
const commonModelPatterns = [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-*",
"claude-sonnet-*",
];
function getBetaDisplayName(token: string): string {
return betaDisplayNames[token] || token;
}
function applyBetaPreset(
rule: (typeof betaPolicyForm.rules)[number],
preset: {
action: "pass" | "filter" | "block";
model_whitelist: string[];
fallback_action: "pass" | "filter" | "block";
},
) {
rule.action = preset.action;
rule.model_whitelist = [...preset.model_whitelist];
rule.fallback_action = preset.fallback_action;
}
function addQuickPattern(
rule: (typeof betaPolicyForm.rules)[number],
pattern: string,
) {
if (!rule.model_whitelist) rule.model_whitelist = [];
if (!rule.model_whitelist.includes(pattern)) {
rule.model_whitelist.push(pattern);
}
}
async function loadBetaPolicySettings() {
betaPolicyLoading.value = true;
try {
const settings = await adminAPI.settings.getBetaPolicySettings();
betaPolicyForm.rules = settings.rules;
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
betaPolicyLoading.value = false;
}
}
// ==================== OpenAI Fast/Flex Policy ====================
const openaiFastPolicyTierOptions = computed(() => [
{ value: "all", label: t("admin.settings.openaiFastPolicy.tierAll") },
{
value: "priority",
label: t("admin.settings.openaiFastPolicy.tierPriority"),
},
{ value: "flex", label: t("admin.settings.openaiFastPolicy.tierFlex") },
]);
const openaiFastPolicyActionOptions = computed(() => [
{ value: "pass", label: t("admin.settings.openaiFastPolicy.actionPass") },
{ value: "filter", label: t("admin.settings.openaiFastPolicy.actionFilter") },
{ value: "block", label: t("admin.settings.openaiFastPolicy.actionBlock") },
]);
const openaiFastPolicyScopeOptions = computed(() => [
{ value: "all", label: t("admin.settings.openaiFastPolicy.scopeAll") },
{ value: "oauth", label: t("admin.settings.openaiFastPolicy.scopeOAuth") },
{ value: "apikey", label: t("admin.settings.openaiFastPolicy.scopeAPIKey") },
{
value: "bedrock",
label: t("admin.settings.openaiFastPolicy.scopeBedrock"),
},
]);
function addOpenAIFastPolicyRule() {
openaiFastPolicyForm.rules.push({
service_tier: "priority",
action: "filter",
scope: "all",
error_message: "",
model_whitelist: [],
fallback_action: "pass",
fallback_error_message: "",
});
}
function removeOpenAIFastPolicyRule(index: number) {
openaiFastPolicyForm.rules.splice(index, 1);
}
function addOpenAIFastPolicyModelPattern(rule: OpenAIFastPolicyRule) {
if (!rule.model_whitelist) rule.model_whitelist = [];
rule.model_whitelist.push("");
}
function removeOpenAIFastPolicyModelPattern(
rule: OpenAIFastPolicyRule,
idx: number,
) {
rule.model_whitelist?.splice(idx, 1);
}
async function saveBetaPolicySettings() {
betaPolicySaving.value = true;
try {
// Clean up empty patterns before saving
const cleanedRules = betaPolicyForm.rules.map((rule) => {
const whitelist = rule.model_whitelist?.filter((p) => p.trim() !== "");
const hasWhitelist = whitelist && whitelist.length > 0;
return {
beta_token: rule.beta_token,
action: rule.action,
scope: rule.scope,
error_message: rule.error_message,
model_whitelist: hasWhitelist ? whitelist : undefined,
fallback_action: hasWhitelist
? rule.fallback_action || "pass"
: undefined,
fallback_error_message:
hasWhitelist && rule.fallback_action === "block"
? rule.fallback_error_message
: undefined,
};
});
const updated = await adminAPI.settings.updateBetaPolicySettings({
rules: cleanedRules,
});
betaPolicyForm.rules = updated.rules;
appStore.showSuccess(t("admin.settings.betaPolicy.saved"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.betaPolicy.saveFailed")),
);
} finally {
betaPolicySaving.value = false;
}
}
// ==================== Provider Management ====================
const allPaymentTypes = computed(() => [
{ value: "easypay", label: t("payment.methods.easypay") },
{ value: "alipay", label: t("payment.methods.alipay") },
{ value: "wxpay", label: t("payment.methods.wxpay") },
{ value: "stripe", label: t("payment.methods.stripe") },
{ value: "airwallex", label: t("payment.methods.airwallex") },
]);
function isPaymentTypeEnabled(type: string): boolean {
return form.payment_enabled_types.includes(type);
}
const hasAnyPaymentTypeEnabled = computed(
() => form.payment_enabled_types.length > 0,
);
function togglePaymentType(type: string) {
if (form.payment_enabled_types.includes(type)) {
form.payment_enabled_types = form.payment_enabled_types.filter(
(t) => t !== type,
);
// Disable all provider instances matching this type
disableProvidersByType(type);
} else {
form.payment_enabled_types = [...form.payment_enabled_types, type];
}
}
async function disableProvidersByType(type: string) {
const matching = providers.value.filter(
(p) => p.provider_key === type && p.enabled,
);
for (const p of matching) {
try {
await adminAPI.payment.updateProvider(p.id, { enabled: false });
p.enabled = false;
} catch (err: unknown) {
slog("disable provider failed", p.id, err);
}
}
}
function slog(...args: unknown[]) {
console.warn("[payment]", ...args);
}
const providersLoading = ref(false);
const providerSaving = ref(false);
const providers = ref<ProviderInstance[]>([]);
const showProviderDialog = ref(false);
const showDeleteProviderDialog = ref(false);
const editingProvider = ref<ProviderInstance | null>(null);
const deletingProviderId = ref<number | null>(null);
const providerDialogRef = ref<InstanceType<
typeof PaymentProviderDialog
> | null>(null);
const providerKeyOptions = computed(() => [
{ value: "easypay", label: t("admin.settings.payment.providerEasypay") },
{ value: "alipay", label: t("admin.settings.payment.providerAlipay") },
{ value: "wxpay", label: t("admin.settings.payment.providerWxpay") },
{ value: "stripe", label: t("admin.settings.payment.providerStripe") },
{ value: "airwallex", label: t("admin.settings.payment.providerAirwallex") },
]);
const enabledProviderKeyOptions = computed(() => {
const enabled = form.payment_enabled_types;
return providerKeyOptions.value.filter((opt) => enabled.includes(opt.value));
});
const loadBalanceOptions = computed(() => [
{
value: "round-robin",
label: t("admin.settings.payment.strategyRoundRobin"),
},
{
value: "least-amount",
label: t("admin.settings.payment.strategyLeastAmount"),
},
]);
const cancelRateLimitUnitOptions = computed(() => [
{
value: "minute",
label: t("admin.settings.payment.cancelRateLimitUnitMinute"),
},
{ value: "hour", label: t("admin.settings.payment.cancelRateLimitUnitHour") },
{ value: "day", label: t("admin.settings.payment.cancelRateLimitUnitDay") },
]);
const cancelRateLimitModeOptions = computed(() => [
{
value: "rolling",
label: t("admin.settings.payment.cancelRateLimitWindowModeRolling"),
},
{
value: "fixed",
label: t("admin.settings.payment.cancelRateLimitWindowModeFixed"),
},
]);
type ProviderEnablementCandidate = Pick<
ProviderInstance,
"id" | "provider_key" | "supported_types" | "enabled" | "name"
>;
function getProviderVisibleMethods(
provider: ProviderEnablementCandidate,
): Array<"alipay" | "wxpay"> {
if (!provider.enabled) {
return [];
}
const supportedTypes = Array.isArray(provider.supported_types)
? provider.supported_types
: [];
const methods = new Set<"alipay" | "wxpay">();
const addMethod = (type: string) => {
const method = normalizeVisibleMethod(type);
if (method === "alipay" || method === "wxpay") {
methods.add(method);
}
};
if (provider.provider_key === "alipay") {
if (supportedTypes.length === 0) {
methods.add("alipay");
} else {
supportedTypes.forEach((type) => {
if (normalizeVisibleMethod(type) === "alipay") {
methods.add("alipay");
}
});
}
} else if (provider.provider_key === "wxpay") {
if (supportedTypes.length === 0) {
methods.add("wxpay");
} else {
supportedTypes.forEach((type) => {
if (normalizeVisibleMethod(type) === "wxpay") {
methods.add("wxpay");
}
});
}
} else if (provider.provider_key === "easypay") {
supportedTypes.forEach(addMethod);
}
return Array.from(methods);
}
function findProviderEnablementConflict(
candidate: ProviderEnablementCandidate,
): { method: "alipay" | "wxpay"; conflicting: ProviderInstance } | null {
const claimedMethods = getProviderVisibleMethods(candidate);
if (claimedMethods.length === 0) {
return null;
}
for (const other of providers.value) {
if (other.id === candidate.id || !other.enabled) {
continue;
}
const otherMethods = getProviderVisibleMethods(other);
const matchedMethod = claimedMethods.find((method) =>
otherMethods.includes(method),
);
if (matchedMethod) {
return {
method: matchedMethod,
conflicting: other,
};
}
}
return null;
}
function showProviderEnablementConflict(
conflict: { method: "alipay" | "wxpay"; conflicting: ProviderInstance },
) {
appStore.showError(
t("admin.settings.payment.enableConflict", {
method: t(`payment.methods.${conflict.method}`),
provider: conflict.conflicting.name,
}),
);
}
async function loadProviders() {
providersLoading.value = true;
try {
const res = await adminAPI.payment.getProviders();
providers.value = res.data || [];
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, "payment.errors", t("common.error")));
} finally {
providersLoading.value = false;
}
}
function openCreateProvider() {
editingProvider.value = null;
providerDialogRef.value?.reset(
enabledProviderKeyOptions.value[0]?.value || "easypay",
);
showProviderDialog.value = true;
}
function openEditProvider(provider: ProviderInstance) {
editingProvider.value = provider;
providerDialogRef.value?.loadProvider(provider);
showProviderDialog.value = true;
}
async function handleSaveProvider(payload: Partial<ProviderInstance>) {
providerSaving.value = true;
try {
const candidate: ProviderEnablementCandidate = {
id: editingProvider.value?.id ?? 0,
provider_key:
payload.provider_key ?? editingProvider.value?.provider_key ?? "",
supported_types:
payload.supported_types ?? editingProvider.value?.supported_types ?? [],
enabled: payload.enabled ?? editingProvider.value?.enabled ?? false,
name: payload.name ?? editingProvider.value?.name ?? "",
};
const conflict = findProviderEnablementConflict(candidate);
if (conflict) {
showProviderEnablementConflict(conflict);
return;
}
if (editingProvider.value) {
await adminAPI.payment.updateProvider(editingProvider.value.id, payload);
} else {
await adminAPI.payment.createProvider(payload);
}
showProviderDialog.value = false;
// Reload full list (API returns decrypted/formatted data with correct sort order)
await loadProviders();
// Auto-save settings so provider changes take effect immediately
await saveSettings();
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, "payment.errors", t("common.error")));
} finally {
providerSaving.value = false;
}
}
async function handleToggleField(
provider: ProviderInstance,
field: "enabled" | "refund_enabled" | "allow_user_refund",
) {
let newValue: boolean;
if (field === "enabled") newValue = !provider.enabled;
else if (field === "refund_enabled") newValue = !provider.refund_enabled;
else newValue = !provider.allow_user_refund;
if (field === "enabled" && newValue) {
const conflict = findProviderEnablementConflict({
id: provider.id,
provider_key: provider.provider_key,
supported_types: provider.supported_types,
enabled: true,
name: provider.name,
});
if (conflict) {
showProviderEnablementConflict(conflict);
return;
}
}
const payload: Record<string, boolean> = { [field]: newValue };
// Cascade: turning off refund_enabled also turns off allow_user_refund
if (field === "refund_enabled" && !newValue) {
payload.allow_user_refund = false;
}
try {
await adminAPI.payment.updateProvider(provider.id, payload);
await loadProviders();
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, "payment.errors", t("common.error")));
}
}
async function handleToggleType(provider: ProviderInstance, type: string) {
const updated = provider.supported_types.includes(type)
? provider.supported_types.filter((t) => t !== type)
: [...provider.supported_types, type];
const conflict = findProviderEnablementConflict({
id: provider.id,
provider_key: provider.provider_key,
supported_types: updated,
enabled: provider.enabled,
name: provider.name,
});
if (conflict) {
showProviderEnablementConflict(conflict);
return;
}
try {
await adminAPI.payment.updateProvider(provider.id, {
supported_types: updated,
} as any);
await loadProviders();
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, "payment.errors", t("common.error")));
}
}
function confirmDeleteProvider(provider: ProviderInstance) {
deletingProviderId.value = provider.id;
showDeleteProviderDialog.value = true;
}
async function handleReorderProviders(
updates: { id: number; sort_order: number }[],
) {
try {
await Promise.all(
updates.map((u) =>
adminAPI.payment.updateProvider(u.id, {
sort_order: u.sort_order,
} as Partial<ProviderInstance>),
),
);
await loadProviders();
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, "payment.errors", t("common.error")));
loadProviders();
}
}
async function handleDeleteProvider() {
if (!deletingProviderId.value) return;
try {
await adminAPI.payment.deleteProvider(deletingProviderId.value);
appStore.showSuccess(t("common.deleted"));
showDeleteProviderDialog.value = false;
loadProviders();
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, "payment.errors", t("common.error")));
}
}
onMounted(() => {
loadSettings();
loadSubscriptionGroups();
loadAdminApiKey();
loadOverloadCooldownSettings();
loadRateLimit429CooldownSettings();
loadStreamTimeoutSettings();
loadRectifierSettings();
loadBetaPolicySettings();
loadProviders();
});
// =========================
// Affiliate (邀请返利) 专属用户管理
// =========================
interface AffiliateState {
loading: boolean;
entries: AffiliateAdminEntry[];
total: number;
page: number;
pageSize: number;
search: string;
selected: number[];
searchTimer: number | null;
}
const affiliateState = reactive<AffiliateState>({
loading: false,
entries: [],
total: 0,
page: 1,
pageSize: 20,
search: "",
selected: [],
searchTimer: null,
});
// `rate` is typed as string|number because <input type="number"> makes Vue's
// v-model auto-cast the bound value to a Number on every keystroke. We keep
// both shapes and normalize at read time.
interface AffiliateModalState {
open: boolean;
mode: "add" | "edit";
saving: boolean;
userQuery: string;
userResults: AffiliateSimpleUser[];
selectedUser: AffiliateSimpleUser | null;
editingEntry: AffiliateAdminEntry | null;
code: string;
rate: string | number;
searchTimer: number | null;
}
const affiliateModal = reactive<AffiliateModalState>({
open: false,
mode: "add",
saving: false,
userQuery: "",
userResults: [],
selectedUser: null,
editingEntry: null,
code: "",
rate: "",
searchTimer: null,
});
const affiliateBatchModal = reactive<{
open: boolean;
saving: boolean;
rate: string | number;
}>({
open: false,
saving: false,
rate: "",
});
// affiliateConfirmDialog drives the project-standard <ConfirmDialog>. We can't
// `await` the user's response from the dialog component, so the confirm action
// runs from the @confirm callback once the user clicks the dialog's confirm
// button.
const affiliateConfirmDialog = reactive<{
show: boolean;
title: string;
message: string;
confirmText: string;
pending: (() => Promise<unknown>) | null;
}>({
show: false,
title: "",
message: "",
confirmText: "",
pending: null,
});
function openAffiliateConfirm(
title: string,
message: string,
confirmText: string,
fn: () => Promise<unknown>,
) {
affiliateConfirmDialog.title = title;
affiliateConfirmDialog.message = message;
affiliateConfirmDialog.confirmText = confirmText;
affiliateConfirmDialog.pending = fn;
affiliateConfirmDialog.show = true;
}
async function handleAffiliateConfirm() {
const fn = affiliateConfirmDialog.pending;
affiliateConfirmDialog.show = false;
affiliateConfirmDialog.pending = null;
if (!fn) return;
try {
await fn();
appStore.showSuccess(t("common.saved"));
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
}
function cancelAffiliateConfirm() {
affiliateConfirmDialog.show = false;
affiliateConfirmDialog.pending = null;
}
// debounceTimer wires a single timer slot to a callback with a delay,
// canceling any pending invocation. Used for type-as-you-go search inputs.
function debounceTimer(slot: { searchTimer: number | null }, delayMs: number, run: () => void) {
if (slot.searchTimer != null) window.clearTimeout(slot.searchTimer);
slot.searchTimer = window.setTimeout(run, delayMs);
}
// parseRebateRate validates 0-100 numeric input. Returns the parsed number on
// success, null when the field is empty (caller decides empty semantics), or
// undefined on invalid input (after surfacing a toast).
//
// Accepts unknown because <input type="number"> makes Vue's v-model coerce
// the value to Number on each keystroke (e.g. typing "30" lands a `30: number`
// in state, not a `"30": string`). String("") and (30).trim() would crash, so
// we normalize here instead of forcing every caller to remember.
function parseRebateRate(raw: unknown): number | null | undefined {
const s = String(raw ?? "").trim();
if (s === "") return null;
const parsed = Number(s);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 100) {
appStore.showError(t("admin.settings.features.affiliate.modal.errorBadRate"));
return undefined;
}
return parsed;
}
async function loadAffiliateUsers() {
affiliateState.loading = true;
try {
const res = await affiliatesAPI.listUsers({
page: affiliateState.page,
page_size: affiliateState.pageSize,
search: affiliateState.search,
});
affiliateState.entries = res.items ?? [];
affiliateState.total = res.total ?? 0;
// Drop selections that are no longer visible.
const visibleIds = new Set(affiliateState.entries.map((e) => e.user_id));
affiliateState.selected = affiliateState.selected.filter((id) => visibleIds.has(id));
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateState.loading = false;
}
}
function onAffiliateSearchInput() {
debounceTimer(affiliateState, 300, () => {
affiliateState.page = 1;
loadAffiliateUsers();
});
}
function changeAffiliatePage(page: number) {
if (page < 1) return;
affiliateState.page = page;
loadAffiliateUsers();
}
function toggleAffiliateSelectAll(e: Event) {
const checked = (e.target as HTMLInputElement).checked;
affiliateState.selected = checked ? affiliateState.entries.map((entry) => entry.user_id) : [];
}
function toggleAffiliateSelect(userId: number) {
const idx = affiliateState.selected.indexOf(userId);
if (idx >= 0) affiliateState.selected.splice(idx, 1);
else affiliateState.selected.push(userId);
}
// openAffiliateModal opens the add/edit modal, prefilling fields from the
// edited entry when present and resetting them otherwise.
function openAffiliateModal(entry: AffiliateAdminEntry | null) {
affiliateModal.open = true;
affiliateModal.mode = entry ? "edit" : "add";
affiliateModal.userQuery = "";
affiliateModal.userResults = [];
affiliateModal.selectedUser = null;
affiliateModal.editingEntry = entry;
affiliateModal.code = entry?.aff_code_custom ? entry.aff_code : "";
affiliateModal.rate =
entry?.aff_rebate_rate_percent != null ? String(entry.aff_rebate_rate_percent) : "";
}
function closeAffiliateModal() {
affiliateModal.open = false;
if (affiliateModal.searchTimer != null) {
window.clearTimeout(affiliateModal.searchTimer);
affiliateModal.searchTimer = null;
}
}
function onAffiliateUserSearchInput() {
const q = affiliateModal.userQuery.trim();
if (!q) {
affiliateModal.userResults = [];
return;
}
debounceTimer(affiliateModal, 300, async () => {
try {
affiliateModal.userResults = await affiliatesAPI.lookupUsers(q);
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
});
}
// selectAffiliateUser picks a user from the dropdown and collapses the search
// UI. Clearing the result list also clears the visual dropdown.
function selectAffiliateUser(user: AffiliateSimpleUser) {
affiliateModal.selectedUser = user;
affiliateModal.userQuery = "";
affiliateModal.userResults = [];
}
function clearSelectedAffiliateUser() {
affiliateModal.selectedUser = null;
}
// affiliateModalCanSubmit guards the Save button: must have a user picked AND
// produce at least one field change. Without this the admin could "save" an
// empty payload that silently does nothing — the user reported exactly that
// confusion.
const affiliateModalCanSubmit = computed(() => {
if (affiliateModal.mode === "add") {
if (!affiliateModal.selectedUser) return false;
} else if (!affiliateModal.editingEntry) {
return false;
}
const codeFilled = affiliateModal.code.trim() !== "";
const rateFilled = String(affiliateModal.rate ?? "").trim() !== "";
if (codeFilled || rateFilled) return true;
// Edit mode + empty rate input is a meaningful "clear" only if the user
// currently has an exclusive rate to clear.
return (
affiliateModal.mode === "edit" &&
affiliateModal.editingEntry?.aff_rebate_rate_percent != null
);
});
async function submitAffiliateModal() {
if (!affiliateModalCanSubmit.value) {
// Should be unreachable because the button is disabled, but keep a guard.
appStore.showError(t("admin.settings.features.affiliate.modal.errorEmpty"));
return;
}
let userId: number;
if (affiliateModal.mode === "add") {
userId = affiliateModal.selectedUser!.id;
} else {
userId = affiliateModal.editingEntry!.user_id;
}
const payload: Parameters<typeof affiliatesAPI.updateUserSettings>[1] = {};
const codeRaw = affiliateModal.code.trim();
if (codeRaw) payload.aff_code = codeRaw.toUpperCase();
const rateInput = parseRebateRate(affiliateModal.rate);
if (rateInput === undefined) return; // toast already shown
if (rateInput === null) {
if (affiliateModal.mode === "edit" && affiliateModal.editingEntry?.aff_rebate_rate_percent != null) {
payload.clear_rebate_rate = true;
}
} else {
payload.aff_rebate_rate_percent = rateInput;
}
affiliateModal.saving = true;
try {
await affiliatesAPI.updateUserSettings(userId, payload);
appStore.showSuccess(t("common.saved"));
closeAffiliateModal();
affiliateState.page = 1;
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateModal.saving = false;
}
}
// askResetAffiliateUser prompts via the project ConfirmDialog, then on confirm
// calls the backend "reset all" endpoint that clears both the exclusive rate
// AND regenerates the invite code as a system random one.
function askResetAffiliateUser(entry: AffiliateAdminEntry) {
openAffiliateConfirm(
t("admin.settings.features.affiliate.customUsers.resetTitle"),
t("admin.settings.features.affiliate.customUsers.resetMessage", {
email: entry.email || `#${entry.user_id}`,
}),
t("common.delete"),
() => affiliatesAPI.clearUserSettings(entry.user_id),
);
}
function openAffiliateBatchModal() {
if (affiliateState.selected.length === 0) return;
affiliateBatchModal.open = true;
affiliateBatchModal.rate = "";
}
async function submitAffiliateBatchModal() {
const rateInput = parseRebateRate(affiliateBatchModal.rate);
if (rateInput === undefined) return;
const userIDs = [...affiliateState.selected];
const payload: Parameters<typeof affiliatesAPI.batchSetRate>[0] =
rateInput === null
? { user_ids: userIDs, clear: true }
: { user_ids: userIDs, aff_rebate_rate_percent: rateInput };
affiliateBatchModal.saving = true;
try {
await affiliatesAPI.batchSetRate(payload);
appStore.showSuccess(t("common.saved"));
affiliateBatchModal.open = false;
affiliateState.selected = [];
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateBatchModal.saving = false;
}
}
// Load the per-user table the first time the affiliate switch is observed
// as enabled. The form starts disabled and is updated to the server's value
// after the settings load — so this fires either when the saved value is
// truthy on first paint, or when the admin manually toggles it on.
watch(
() => form.affiliate_enabled,
(enabled, prev) => {
if (enabled && !prev) {
loadAffiliateUsers();
}
},
);
// bypass_registration 与身份同步三开关仅在 internal_only 模式下生效。切换 policy 到其它值时,
// 立即把相关字段重置为 false避免保存请求里残留旧值。后端 admin handler 与
// 配置加载层都有 coerce 兜底,这里是 UX 层的同步而非安全防线。
watch(
() => form.dingtalk_connect_corp_restriction_policy,
(policy) => {
if (policy !== "internal_only") {
if (form.dingtalk_connect_bypass_registration) form.dingtalk_connect_bypass_registration = false;
if (form.dingtalk_connect_sync_corp_email) form.dingtalk_connect_sync_corp_email = false;
if (form.dingtalk_connect_sync_display_name) form.dingtalk_connect_sync_display_name = false;
if (form.dingtalk_connect_sync_dept) form.dingtalk_connect_sync_dept = false;
}
},
);
</script>
<style scoped>
.default-sub-group-select :deep(.select-trigger) {
@apply h-[42px];
}
.default-sub-delete-btn {
@apply h-[42px];
}
/* ============ 系统设置 Tab 导航 ============ */
.settings-tabs-shell {
@apply sticky z-20 -mx-1 rounded-2xl border border-white/80 bg-white/90 p-1.5 backdrop-blur-xl;
top: 4.75rem;
box-shadow:
0 12px 28px rgb(15 23 42 / 0.07),
0 1px 0 rgb(255 255 255 / 0.9) inset;
}
.settings-tabs-scroll {
@apply overflow-x-auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
.settings-tabs-scroll::-webkit-scrollbar {
display: none;
}
.settings-tabs {
@apply flex min-w-max items-center gap-1;
}
.settings-tab {
@apply relative isolate flex h-10 min-w-[6.75rem] shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-xl border border-transparent px-3 text-sm font-medium text-gray-600 outline-none transition-colors duration-200 ease-out dark:text-gray-300;
}
@media (min-width: 768px) {
.settings-tabs {
@apply min-w-full;
}
.settings-tab {
@apply min-w-0 flex-1 basis-0 overflow-hidden px-2 text-[13px];
}
.settings-tab-icon {
@apply h-6 w-6;
}
}
.settings-tab::before {
@apply absolute inset-0 -z-10 rounded-xl opacity-0 transition-opacity duration-200;
content: "";
background: linear-gradient(135deg, rgb(248 250 252 / 0.95), rgb(241 245 249 / 0.8));
}
.settings-tab:hover::before,
.settings-tab:focus-visible::before {
opacity: 1;
}
.settings-tab:focus-visible {
@apply ring-2 ring-primary-500/40 ring-offset-2 ring-offset-white dark:ring-offset-dark-900;
}
.settings-tab-active {
@apply border-primary-200/80 bg-white text-primary-700 shadow-sm dark:border-primary-400/30 dark:bg-dark-700/95 dark:text-primary-200;
box-shadow:
0 8px 18px rgb(15 23 42 / 0.08),
0 1px 0 rgb(255 255 255 / 0.92) inset;
}
.settings-tab-active::before {
opacity: 0;
}
.settings-tab-active::after {
position: absolute;
right: 0.75rem;
bottom: 0.25rem;
left: 0.75rem;
height: 2px;
border-radius: 9999px;
content: "";
background: linear-gradient(90deg, #14b8a6, #0ea5e9);
}
.settings-tab-icon {
@apply flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-gray-500 transition-colors duration-200 dark:text-gray-400;
}
.settings-tab:hover .settings-tab-icon,
.settings-tab:focus-visible .settings-tab-icon {
@apply text-gray-700 dark:text-gray-200;
}
.settings-tab-active .settings-tab-icon {
@apply bg-primary-50 text-primary-600 dark:bg-primary-400/10 dark:text-primary-300;
}
.settings-tab-label {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap leading-none;
}
</style>
<style>
/* Dark-mode overrides for the settings tabs shell. Kept in an UNSCOPED block
because Vue's scoped-CSS compiler was dropping the `:global(.dark) ...`
rules in the production build, leaving inactive tabs unreadable on dark. */
.dark .settings-tabs-shell {
border-color: rgb(51 65 85 / 0.65);
background: rgb(15 23 42 / 0.86);
box-shadow:
0 16px 36px rgb(0 0 0 / 0.28),
0 1px 0 rgb(255 255 255 / 0.06) inset;
}
.dark .settings-tab::before {
background: linear-gradient(135deg, rgb(30 41 59 / 0.9), rgb(51 65 85 / 0.62));
}
.dark .settings-tab-active {
box-shadow:
0 12px 26px rgb(0 0 0 / 0.22),
0 1px 0 rgb(255 255 255 / 0.08) inset;
}
</style>