fix(websearch): improve settings UI and hide config when globally disabled

- API Key show/copy buttons moved inside input field (inline icons)
- Proxy selector and test button on same row to save vertical space
- Test opens a dialog modal instead of inline display
- Hide all websearch config in channels/accounts when global toggle is off
This commit is contained in:
erio
2026-04-12 15:59:45 +08:00
parent cef22c70ab
commit 889b5b4f3b
5 changed files with 177 additions and 86 deletions

View File

@@ -306,6 +306,21 @@
</div>
</div>
<!-- Web Search Emulation (Anthropic only, hidden when global disabled) -->
<div v-if="section.platform === 'anthropic' && webSearchGlobalEnabled" class="border-t border-gray-200 pt-3 dark:border-dark-600">
<div class="flex items-center justify-between">
<div>
<label class="text-xs font-medium text-orange-600 dark:text-orange-400">
{{ t('admin.channels.form.webSearchEmulation') }}
</label>
<p class="mt-0.5 text-[11px] text-amber-500 dark:text-amber-400">
{{ t('admin.channels.form.webSearchEmulationHint') }}
</p>
</div>
<Toggle v-model="section.web_search_emulation" />
</div>
</div>
<!-- Model Mapping -->
<div>
<div class="mb-1 flex items-center justify-between">
@@ -560,6 +575,7 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels'
import type { PricingFormEntry } from '@/components/admin/channel/types'
@@ -583,6 +599,18 @@ import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
const { t } = useI18n()
const appStore = useAppStore()
// Web Search global enabled state (loaded once on mount)
const webSearchGlobalEnabled = ref(false)
async function loadWebSearchGlobalState() {
try {
const cfg = await adminAPI.settings.getWebSearchEmulationConfig()
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
} catch (err: unknown) {
console.warn('Failed to load web search global state:', err)
webSearchGlobalEnabled.value = false
}
}
// ── Platform Section type ──
interface PlatformSection {
platform: GroupPlatform
@@ -591,6 +619,7 @@ interface PlatformSection {
group_ids: number[]
model_mapping: Record<string, string>
model_pricing: PricingFormEntry[]
web_search_emulation: boolean
}
// ── Table columns ──
@@ -709,7 +738,8 @@ function addPlatformSection(platform: GroupPlatform) {
collapsed: false,
group_ids: [],
model_mapping: {},
model_pricing: []
model_pricing: [],
web_search_emulation: false,
})
}
@@ -901,10 +931,14 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
}
// ── Form ↔ API conversion ──
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>> } {
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>>, features_config: Record<string, unknown> } {
const group_ids: number[] = []
const model_pricing: ChannelModelPricing[] = []
const model_mapping: Record<string, Record<string, string>> = {}
// Preserve existing features_config fields not managed by the form
const featuresConfig: Record<string, unknown> = editingChannel.value?.features_config
? { ...editingChannel.value.features_config }
: {}
for (const section of form.platforms) {
if (!section.enabled) continue
@@ -933,7 +967,19 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
}
}
return { group_ids, model_pricing, model_mapping }
// Collect web_search_emulation (only anthropic platform supports it)
const wsEmulation: Record<string, boolean> = {}
for (const section of form.platforms) {
if (!section.enabled) continue
if (section.web_search_emulation && section.platform === 'anthropic') {
wsEmulation[section.platform] = true
}
}
if (Object.keys(wsEmulation).length > 0) {
featuresConfig.web_search_emulation = wsEmulation
}
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
}
function apiToForm(channel: Channel): PlatformSection[] {
@@ -977,13 +1023,19 @@ function apiToForm(channel: Channel): PlatformSection[] {
intervals: apiIntervalsToForm(p.intervals || [])
} as PricingFormEntry))
// Read web_search_emulation from features_config
const fc = channel.features_config
const wsEmulation = fc?.web_search_emulation as Record<string, boolean> | undefined
const webSearchEnabled = wsEmulation?.[platform] === true
sections.push({
platform,
enabled: true,
collapsed: false,
group_ids: groupIds,
model_mapping: { ...mapping },
model_pricing: pricing
model_pricing: pricing,
web_search_emulation: webSearchEnabled,
})
}
@@ -1008,10 +1060,10 @@ async function loadChannels() {
if (ctrl.signal.aborted || abortController !== ctrl) return
channels.value = response.items || []
pagination.total = response.total
} catch (error: any) {
if (error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return
appStore.showError(t('admin.channels.loadError', 'Failed to load channels'))
console.error('Error loading channels:', error)
} catch (error: unknown) {
const e = error as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(error, t('admin.channels.loadError', 'Failed to load channels')))
} finally {
if (abortController === ctrl) {
loading.value = false
@@ -1210,7 +1262,7 @@ async function handleSubmit() {
}
}
const { group_ids, model_pricing, model_mapping } = formToAPI()
const { group_ids, model_pricing, model_mapping, features_config } = formToAPI()
submitting.value = true
try {
@@ -1224,6 +1276,7 @@ async function handleSubmit() {
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
}
@@ -1238,6 +1291,7 @@ async function handleSubmit() {
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
}
@@ -1246,12 +1300,10 @@ async function handleSubmit() {
}
closeDialog()
loadChannels()
} catch (error: any) {
const msg = error.response?.data?.detail || (editingChannel.value
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, editingChannel.value
? t('admin.channels.updateError', 'Failed to update channel')
: t('admin.channels.createError', 'Failed to create channel'))
appStore.showError(msg)
console.error('Error saving channel:', error)
: t('admin.channels.createError', 'Failed to create channel')))
} finally {
submitting.value = false
}
@@ -1289,9 +1341,8 @@ async function confirmDelete() {
showDeleteDialog.value = false
deletingChannel.value = null
loadChannels()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.channels.deleteError', 'Failed to delete channel'))
console.error('Error deleting channel:', error)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.channels.deleteError', 'Failed to delete channel')))
}
}
@@ -1299,6 +1350,7 @@ async function confirmDelete() {
onMounted(() => {
loadChannels()
loadGroups()
loadWebSearchGlobalState()
})
onUnmounted(() => {

View File

@@ -1789,40 +1789,42 @@
<!-- 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 -->
<!-- API Key with inline show/copy -->
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.apiKey') }}</label>
<div class="flex items-center gap-1">
<div class="relative">
<input
v-model="provider.api_key"
:type="apiKeyVisible[pIdx] ? 'text' : 'password'"
class="input flex-1 text-sm"
class="input w-full pr-16 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 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"
: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>
@@ -1858,44 +1860,19 @@
<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>
<!-- 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>
@@ -1903,6 +1880,50 @@
</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 -->
@@ -3016,6 +3037,12 @@ 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]