feat(websearch): settings UI overhaul and quota improvements
- Remove Priority field, auto load-balance by quota remaining - Replace QuotaRefreshInterval (daily/weekly/monthly) with SubscribedAt (subscription date, monthly lazy refresh via Redis TTL) - Add collapsible provider cards, API key show/copy, usage progress bar - Add test endpoint (POST /web-search-emulation/test) bypassing quota - Wire WebSearchManagerBuilder on startup (was never called before) - Fix nextMonthlyReset day-of-month overflow (Jan 31 → Feb 28) - Fix non-deterministic sort in selectByQuotaWeight - Map ProxyID in builder for provider-level proxy tracking - Fix frontend timezone drift in subscribed_at date picker - Fix provider deletion index shift for expandedProviders state
This commit is contained in:
@@ -1751,61 +1751,152 @@
|
||||
</div>
|
||||
|
||||
<div v-for="(provider, pIdx) in webSearchConfig.providers" :key="pIdx"
|
||||
class="rounded-lg border border-gray-200 p-4 space-y-3 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<Select
|
||||
v-model="provider.type"
|
||||
:options="[
|
||||
{ value: 'brave', label: 'Brave Search' },
|
||||
{ value: 'tavily', label: 'Tavily' },
|
||||
]"
|
||||
class="w-40"
|
||||
/>
|
||||
<button type="button" class="text-red-500 hover:text-red-700 text-xs" @click="webSearchConfig.providers.splice(pIdx, 1)">
|
||||
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 in collapsed state -->
|
||||
<span v-if="!expandedProviders[pIdx] && provider.quota_limit > 0" class="text-xs text-gray-400">
|
||||
{{ provider.quota_used ?? 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>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 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 show/copy -->
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.apiKey') }}</label>
|
||||
<input
|
||||
v-model="provider.api_key"
|
||||
type="password"
|
||||
class="input text-sm"
|
||||
:placeholder="provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
v-model="provider.api_key"
|
||||
:type="apiKeyVisible[pIdx] ? 'text' : 'password'"
|
||||
class="input flex-1 text-sm"
|
||||
:placeholder="provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm px-2"
|
||||
: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="btn btn-secondary btn-sm px-2"
|
||||
:title="t('admin.settings.webSearchEmulation.copyApiKey')"
|
||||
@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>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.priority') }}</label>
|
||||
<input v-model.number="provider.priority" type="number" min="1" class="input text-sm" />
|
||||
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.priorityHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaLimit') }}</label>
|
||||
<input v-model.number="provider.quota_limit" type="number" min="0" class="input text-sm" />
|
||||
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.quotaLimitHint') }}</p>
|
||||
<p v-if="provider.quota_used != null" class="mt-0.5 text-xs text-gray-400">
|
||||
{{ t('admin.settings.webSearchEmulation.quotaUsed') }}: {{ provider.quota_used }} / {{ provider.quota_limit || '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaRefreshInterval') }}</label>
|
||||
<Select
|
||||
v-model="provider.quota_refresh_interval"
|
||||
:options="[
|
||||
{ value: 'daily', label: t('admin.settings.webSearchEmulation.daily') },
|
||||
{ value: 'weekly', label: t('admin.settings.webSearchEmulation.weekly') },
|
||||
{ value: 'monthly', label: t('admin.settings.webSearchEmulation.monthly') },
|
||||
]"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
|
||||
<ProxySelector v-model="provider.proxy_id" />
|
||||
<!-- 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.number="provider.quota_limit" type="number" min="0" class="input text-sm" />
|
||||
<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 v-if="provider.quota_limit > 0" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaUsage') }}:</span>
|
||||
<div 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>
|
||||
<span class="text-xs text-gray-500">{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Proxy -->
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
|
||||
<ProxySelector v-model="provider.proxy_id" :proxies="webSearchProxies" />
|
||||
</div>
|
||||
|
||||
<!-- Test button -->
|
||||
<div class="border-t border-gray-100 pt-3 dark:border-dark-700">
|
||||
<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-secondary 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-2 rounded-lg bg-gray-50 p-3 text-xs dark:bg-dark-700">
|
||||
<p class="mb-1 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-gray-400">
|
||||
{{ t('admin.settings.webSearchEmulation.testNoResults') }}
|
||||
</div>
|
||||
<div v-for="(r, rIdx) in wsTestResult.results.slice(0, 3)" :key="rIdx" class="mt-1">
|
||||
<a :href="r.url" target="_blank" class="font-medium text-blue-600 hover:underline dark:text-blue-400">{{ r.title }}</a>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ r.snippet && r.snippet.length > 120 ? r.snippet.slice(0, 120) + '...' : r.snippet }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2303,6 +2394,13 @@
|
||||
]"
|
||||
>{{ pt.label }}</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t('admin.settings.payment.enabledPaymentTypesHint') }}
|
||||
<a :href="locale === 'zh' ? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F' : 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'" 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">
|
||||
@@ -2562,60 +2660,6 @@
|
||||
</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>
|
||||
</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>
|
||||
<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="(_, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
|
||||
<input v-model="form.account_quota_notify_emails[index]" type="email" class="input flex-1" />
|
||||
<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 -->
|
||||
@@ -2674,8 +2718,9 @@ import type {
|
||||
DefaultSubscriptionSetting,
|
||||
WebSearchEmulationConfig,
|
||||
WebSearchProviderConfig,
|
||||
WebSearchTestResult,
|
||||
} from '@/api/admin/settings'
|
||||
import type { AdminGroup } from '@/types'
|
||||
import type { AdminGroup, Proxy } from '@/types'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -2894,13 +2939,12 @@ const form = reactive<SettingsForm>({
|
||||
// Gateway forwarding behavior
|
||||
enable_fingerprint_unification: true,
|
||||
enable_metadata_passthrough: false,
|
||||
enable_cch_signing: false,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
account_quota_notify_emails: [] as string[]
|
||||
enable_cch_signing: false
|
||||
})
|
||||
|
||||
// Proxies for web search emulation ProxySelector
|
||||
const webSearchProxies = ref<Proxy[]>([])
|
||||
|
||||
// Web Search Emulation config (loaded/saved separately)
|
||||
const DEFAULT_WEB_SEARCH_QUOTA_LIMIT = 1000
|
||||
|
||||
@@ -2909,26 +2953,101 @@ const webSearchConfig = reactive<WebSearchEmulationConfig>({
|
||||
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)
|
||||
|
||||
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,
|
||||
priority: webSearchConfig.providers.length + 1,
|
||||
quota_limit: DEFAULT_WEB_SEARCH_QUOTA_LIMIT,
|
||||
quota_refresh_interval: 'monthly',
|
||||
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 copyApiKey(idx: number) {
|
||||
const key = webSearchConfig.providers[idx]?.api_key
|
||||
if (!key) {
|
||||
appStore.showError(t('admin.settings.webSearchEmulation.apiKeyPlaceholder'))
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(key)
|
||||
appStore.showSuccess(t('admin.settings.webSearchEmulation.copied'))
|
||||
}
|
||||
|
||||
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 = await adminAPI.settings.getWebSearchEmulationConfig()
|
||||
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
|
||||
@@ -3030,14 +3149,6 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Quota notify email helpers
|
||||
const addQuotaNotifyEmail = () => {
|
||||
if (!form.account_quota_notify_emails) {
|
||||
form.account_quota_notify_emails = []
|
||||
}
|
||||
form.account_quota_notify_emails.push('')
|
||||
}
|
||||
|
||||
// LinuxDo OAuth redirect URL suggestion
|
||||
const linuxdoRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
@@ -3377,10 +3488,6 @@ async function saveSettings() {
|
||||
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,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
|
||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e: string) => e.trim() !== ''),
|
||||
}
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
|
||||
Reference in New Issue
Block a user