feat: websearch quota enhancements and balance notify hint
- QuotaLimit changed to *int64 (null=unlimited, >0=limited) - Add reset-usage endpoint (POST /admin/settings/web-search-emulation/reset-usage) - Show quota usage in header always (collapsed and expanded) - Add reset quota button in expanded provider view - Quota input: empty=unlimited with ∞ placeholder, must be >0 if set - Add email verification hint on balance notify card
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0.1.112.3
|
0.1.112.4
|
||||||
|
|||||||
@@ -1962,7 +1962,28 @@ func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) {
|
|||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Success(c, service.SanitizeWebSearchConfig(c.Request.Context(), updated))
|
response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetWebSearchUsage 重置指定 provider 的配额用量
|
||||||
|
// POST /api/v1/admin/settings/web-search-emulation/reset-usage
|
||||||
|
func (h *SettingHandler) ResetWebSearchUsage(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ProviderType string `json:"provider_type"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ProviderType == "" {
|
||||||
|
response.BadRequest(c, "provider_type is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.ResetWebSearchUsage(c.Request.Context(), req.ProviderType); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebSearchEmulation 测试 Web Search 搜索
|
// TestWebSearchEmulation 测试 Web Search 搜索
|
||||||
|
|||||||
@@ -447,6 +447,15 @@ func (m *Manager) GetAllUsage(ctx context.Context) map[string]int64 {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetUsage deletes the Redis quota key for the given provider, resetting usage to 0.
|
||||||
|
func (m *Manager) ResetUsage(ctx context.Context, providerType string) error {
|
||||||
|
if m.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := quotaRedisKey(providerType)
|
||||||
|
return m.redis.Del(ctx, key).Err()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Provider factory ---
|
// --- Provider factory ---
|
||||||
|
|
||||||
func (m *Manager) buildProvider(cfg ProviderConfig, client *http.Client) Provider {
|
func (m *Manager) buildProvider(cfg ProviderConfig, client *http.Client) Provider {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func ProvideRouter(
|
|||||||
pc := websearch.ProviderConfig{
|
pc := websearch.ProviderConfig{
|
||||||
Type: p.Type,
|
Type: p.Type,
|
||||||
APIKey: p.APIKey,
|
APIKey: p.APIKey,
|
||||||
QuotaLimit: p.QuotaLimit,
|
QuotaLimit: derefInt64(p.QuotaLimit),
|
||||||
ExpiresAt: p.ExpiresAt,
|
ExpiresAt: p.ExpiresAt,
|
||||||
}
|
}
|
||||||
if p.SubscribedAt != nil {
|
if p.SubscribedAt != nil {
|
||||||
@@ -141,3 +141,10 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server {
|
|||||||
// 不设置 ReadTimeout,因为大请求体可能需要较长时间读取
|
// 不设置 ReadTimeout,因为大请求体可能需要较长时间读取
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefInt64(p *int64) int64 {
|
||||||
|
if p == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
adminSettings.GET("/web-search-emulation", h.Admin.Setting.GetWebSearchEmulationConfig)
|
adminSettings.GET("/web-search-emulation", h.Admin.Setting.GetWebSearchEmulationConfig)
|
||||||
adminSettings.PUT("/web-search-emulation", h.Admin.Setting.UpdateWebSearchEmulationConfig)
|
adminSettings.PUT("/web-search-emulation", h.Admin.Setting.UpdateWebSearchEmulationConfig)
|
||||||
adminSettings.POST("/web-search-emulation/test", h.Admin.Setting.TestWebSearchEmulation)
|
adminSettings.POST("/web-search-emulation/test", h.Admin.Setting.TestWebSearchEmulation)
|
||||||
|
adminSettings.POST("/web-search-emulation/reset-usage", h.Admin.Setting.ResetWebSearchUsage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type WebSearchProviderConfig struct {
|
|||||||
Type string `json:"type"` // websearch.ProviderTypeBrave | Tavily
|
Type string `json:"type"` // websearch.ProviderTypeBrave | Tavily
|
||||||
APIKey string `json:"api_key,omitempty"` // secret — omitted in API responses
|
APIKey string `json:"api_key,omitempty"` // secret — omitted in API responses
|
||||||
APIKeyConfigured bool `json:"api_key_configured"` // read-only mask
|
APIKeyConfigured bool `json:"api_key_configured"` // read-only mask
|
||||||
QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited
|
QuotaLimit *int64 `json:"quota_limit"` // nil = unlimited, >0 = limited
|
||||||
SubscribedAt *int64 `json:"subscribed_at,omitempty"` // subscription start (unix seconds); quota resets monthly
|
SubscribedAt *int64 `json:"subscribed_at,omitempty"` // subscription start (unix seconds); quota resets monthly
|
||||||
QuotaUsed int64 `json:"quota_used,omitempty"` // read-only: current usage from Redis
|
QuotaUsed int64 `json:"quota_used,omitempty"` // read-only: current usage from Redis
|
||||||
ProxyID *int64 `json:"proxy_id"` // optional proxy association
|
ProxyID *int64 `json:"proxy_id"` // optional proxy association
|
||||||
@@ -52,8 +52,8 @@ func validateWebSearchConfig(cfg *WebSearchEmulationConfig) error {
|
|||||||
if !validProviderTypes[p.Type] {
|
if !validProviderTypes[p.Type] {
|
||||||
return fmt.Errorf("provider[%d]: invalid type %q", i, p.Type)
|
return fmt.Errorf("provider[%d]: invalid type %q", i, p.Type)
|
||||||
}
|
}
|
||||||
if p.QuotaLimit < 0 {
|
if p.QuotaLimit != nil && *p.QuotaLimit < 0 {
|
||||||
return fmt.Errorf("provider[%d]: quota_limit must be >= 0", i)
|
return fmt.Errorf("provider[%d]: quota_limit must be > 0 or null", i)
|
||||||
}
|
}
|
||||||
if seen[p.Type] {
|
if seen[p.Type] {
|
||||||
return fmt.Errorf("provider[%d]: duplicate type %q", i, p.Type)
|
return fmt.Errorf("provider[%d]: duplicate type %q", i, p.Type)
|
||||||
@@ -299,6 +299,15 @@ func PopulateWebSearchUsage(ctx context.Context, cfg *WebSearchEmulationConfig)
|
|||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetWebSearchUsage deletes the Redis quota key for the given provider type.
|
||||||
|
func ResetWebSearchUsage(ctx context.Context, providerType string) error {
|
||||||
|
mgr := getWebSearchManager()
|
||||||
|
if mgr == nil {
|
||||||
|
return fmt.Errorf("web search manager not initialized")
|
||||||
|
}
|
||||||
|
return mgr.ResetUsage(ctx, providerType)
|
||||||
|
}
|
||||||
|
|
||||||
// SanitizeWebSearchConfig returns a copy with api_key fields masked and quota usage populated.
|
// SanitizeWebSearchConfig returns a copy with api_key fields masked and quota usage populated.
|
||||||
func SanitizeWebSearchConfig(ctx context.Context, cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig {
|
func SanitizeWebSearchConfig(ctx context.Context, cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ func TestValidateWebSearchConfig_Valid(t *testing.T) {
|
|||||||
cfg := &WebSearchEmulationConfig{
|
cfg := &WebSearchEmulationConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Providers: []WebSearchProviderConfig{
|
Providers: []WebSearchProviderConfig{
|
||||||
{Type: "brave", QuotaLimit: 1000},
|
{Type: "brave", QuotaLimit: int64Ptr(1000)},
|
||||||
{Type: "tavily", QuotaLimit: 500},
|
{Type: "tavily", QuotaLimit: int64Ptr(500)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, validateWebSearchConfig(cfg))
|
require.NoError(t, validateWebSearchConfig(cfg))
|
||||||
@@ -42,9 +42,9 @@ func TestValidateWebSearchConfig_InvalidType(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidateWebSearchConfig_NegativeQuotaLimit(t *testing.T) {
|
func TestValidateWebSearchConfig_NegativeQuotaLimit(t *testing.T) {
|
||||||
cfg := &WebSearchEmulationConfig{
|
cfg := &WebSearchEmulationConfig{
|
||||||
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: -1}},
|
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: int64Ptr(-1)}},
|
||||||
}
|
}
|
||||||
require.ErrorContains(t, validateWebSearchConfig(cfg), "quota_limit must be >= 0")
|
require.ErrorContains(t, validateWebSearchConfig(cfg), "quota_limit must be > 0 or null")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateWebSearchConfig_DuplicateType(t *testing.T) {
|
func TestValidateWebSearchConfig_DuplicateType(t *testing.T) {
|
||||||
@@ -57,9 +57,9 @@ func TestValidateWebSearchConfig_DuplicateType(t *testing.T) {
|
|||||||
require.ErrorContains(t, validateWebSearchConfig(cfg), "duplicate type")
|
require.ErrorContains(t, validateWebSearchConfig(cfg), "duplicate type")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateWebSearchConfig_ZeroQuotaLimit(t *testing.T) {
|
func TestValidateWebSearchConfig_NilQuotaLimit(t *testing.T) {
|
||||||
cfg := &WebSearchEmulationConfig{
|
cfg := &WebSearchEmulationConfig{
|
||||||
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: 0}},
|
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: nil}},
|
||||||
}
|
}
|
||||||
require.NoError(t, validateWebSearchConfig(cfg))
|
require.NoError(t, validateWebSearchConfig(cfg))
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func TestParseWebSearchConfigJSON_BackwardCompatibility(t *testing.T) {
|
|||||||
cfg := parseWebSearchConfigJSON(raw)
|
cfg := parseWebSearchConfigJSON(raw)
|
||||||
require.True(t, cfg.Enabled)
|
require.True(t, cfg.Enabled)
|
||||||
require.Len(t, cfg.Providers, 1)
|
require.Len(t, cfg.Providers, 1)
|
||||||
require.Equal(t, int64(1000), cfg.Providers[0].QuotaLimit)
|
require.Equal(t, int64(1000), *cfg.Providers[0].QuotaLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SanitizeWebSearchConfig ---
|
// --- SanitizeWebSearchConfig ---
|
||||||
@@ -126,12 +126,12 @@ func TestSanitizeWebSearchConfig_PreservesOtherFields(t *testing.T) {
|
|||||||
cfg := &WebSearchEmulationConfig{
|
cfg := &WebSearchEmulationConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Providers: []WebSearchProviderConfig{
|
Providers: []WebSearchProviderConfig{
|
||||||
{Type: "brave", APIKey: "secret", QuotaLimit: 1000},
|
{Type: "brave", APIKey: "secret", QuotaLimit: int64Ptr(1000)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
out := SanitizeWebSearchConfig(context.Background(), cfg)
|
out := SanitizeWebSearchConfig(context.Background(), cfg)
|
||||||
require.True(t, out.Enabled)
|
require.True(t, out.Enabled)
|
||||||
require.Equal(t, int64(1000), out.Providers[0].QuotaLimit)
|
require.Equal(t, int64(1000), *out.Providers[0].QuotaLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeWebSearchConfig_DoesNotMutateOriginal(t *testing.T) {
|
func TestSanitizeWebSearchConfig_DoesNotMutateOriginal(t *testing.T) {
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ export interface WebSearchProviderConfig {
|
|||||||
type: 'brave' | 'tavily'
|
type: 'brave' | 'tavily'
|
||||||
api_key: string
|
api_key: string
|
||||||
api_key_configured: boolean
|
api_key_configured: boolean
|
||||||
quota_limit: number
|
quota_limit: number | null
|
||||||
subscribed_at: number | null
|
subscribed_at: number | null
|
||||||
quota_used?: number
|
quota_used?: number
|
||||||
proxy_id: number | null
|
proxy_id: number | null
|
||||||
@@ -547,6 +547,12 @@ export async function testWebSearchEmulation(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetWebSearchUsage(
|
||||||
|
payload: { provider_type: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.post('/admin/settings/web-search-emulation/reset-usage', payload)
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsAPI = {
|
export const settingsAPI = {
|
||||||
getSettings,
|
getSettings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
@@ -565,7 +571,8 @@ export const settingsAPI = {
|
|||||||
updateBetaPolicySettings,
|
updateBetaPolicySettings,
|
||||||
getWebSearchEmulationConfig,
|
getWebSearchEmulationConfig,
|
||||||
updateWebSearchEmulationConfig,
|
updateWebSearchEmulationConfig,
|
||||||
testWebSearchEmulation
|
testWebSearchEmulation,
|
||||||
|
resetWebSearchUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
export default settingsAPI
|
export default settingsAPI
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
<!-- Email list with toggles -->
|
<!-- Email list with toggles -->
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
||||||
|
<p class="mb-2 text-xs text-yellow-600 dark:text-yellow-400">{{ t('profile.balanceNotify.extraEmailsHint') }}</p>
|
||||||
|
|
||||||
<!-- Saved email entries -->
|
<!-- Saved email entries -->
|
||||||
<div v-if="emailEntries.length > 0" class="space-y-2 mb-3">
|
<div v-if="emailEntries.length > 0" class="space-y-2 mb-3">
|
||||||
|
|||||||
@@ -914,6 +914,7 @@ export default {
|
|||||||
thresholdPlaceholder: 'Enter amount',
|
thresholdPlaceholder: 'Enter amount',
|
||||||
systemDefault: 'System Default',
|
systemDefault: 'System Default',
|
||||||
extraEmails: 'Notification Emails',
|
extraEmails: 'Notification Emails',
|
||||||
|
extraEmailsHint: 'You must add and verify an email address to receive low balance alerts',
|
||||||
primaryEmail: 'Primary',
|
primaryEmail: 'Primary',
|
||||||
noExtraEmails: 'No extra notification emails',
|
noExtraEmails: 'No extra notification emails',
|
||||||
enterEmail: 'Enter email address',
|
enterEmail: 'Enter email address',
|
||||||
@@ -4435,10 +4436,14 @@ export default {
|
|||||||
copyApiKey: 'Copy',
|
copyApiKey: 'Copy',
|
||||||
copied: 'Copied',
|
copied: 'Copied',
|
||||||
quotaLimit: 'Quota Limit',
|
quotaLimit: 'Quota Limit',
|
||||||
quotaLimitHint: '0 = unlimited',
|
quotaLimitHint: 'Leave empty for unlimited; must be > 0 if set',
|
||||||
|
quotaLimitMustBePositive: 'Quota limit must be greater than 0',
|
||||||
subscribedAt: 'Subscribed At',
|
subscribedAt: 'Subscribed At',
|
||||||
subscribedAtHint: 'Quota resets monthly from this date',
|
subscribedAtHint: 'Quota resets monthly from this date; leave empty to disable auto-reset',
|
||||||
quotaUsage: 'Usage',
|
quotaUsage: 'Usage',
|
||||||
|
resetUsage: 'Reset',
|
||||||
|
resetUsageConfirm: 'Reset usage counter for this provider?',
|
||||||
|
resetUsageSuccess: 'Usage counter reset',
|
||||||
proxy: 'Proxy',
|
proxy: 'Proxy',
|
||||||
removeProvider: 'Remove',
|
removeProvider: 'Remove',
|
||||||
noProviders: 'No search providers configured',
|
noProviders: 'No search providers configured',
|
||||||
|
|||||||
@@ -918,6 +918,7 @@ export default {
|
|||||||
thresholdPlaceholder: '输入金额',
|
thresholdPlaceholder: '输入金额',
|
||||||
systemDefault: '系统默认值',
|
systemDefault: '系统默认值',
|
||||||
extraEmails: '通知邮箱',
|
extraEmails: '通知邮箱',
|
||||||
|
extraEmailsHint: '必须添加并验证邮箱后,余额不足时才能收到提醒邮件',
|
||||||
primaryEmail: '主邮箱',
|
primaryEmail: '主邮箱',
|
||||||
noExtraEmails: '暂无额外通知邮箱',
|
noExtraEmails: '暂无额外通知邮箱',
|
||||||
enterEmail: '输入邮箱地址',
|
enterEmail: '输入邮箱地址',
|
||||||
@@ -4597,10 +4598,14 @@ export default {
|
|||||||
copyApiKey: '复制',
|
copyApiKey: '复制',
|
||||||
copied: '已复制',
|
copied: '已复制',
|
||||||
quotaLimit: '配额上限',
|
quotaLimit: '配额上限',
|
||||||
quotaLimitHint: '0 表示无限制',
|
quotaLimitHint: '留空表示无限制;填写时必须大于 0',
|
||||||
|
quotaLimitMustBePositive: '配额上限必须大于 0',
|
||||||
subscribedAt: '订阅时间',
|
subscribedAt: '订阅时间',
|
||||||
subscribedAtHint: '配额从此日期起每月自动重置',
|
subscribedAtHint: '配额从此日期起每月自动重置;留空则不自动重置',
|
||||||
quotaUsage: '用量',
|
quotaUsage: '用量',
|
||||||
|
resetUsage: '重置',
|
||||||
|
resetUsageConfirm: '确定要重置此服务商的用量计数吗?',
|
||||||
|
resetUsageSuccess: '用量已重置',
|
||||||
proxy: '代理',
|
proxy: '代理',
|
||||||
removeProvider: '删除',
|
removeProvider: '删除',
|
||||||
noProviders: '未配置搜索服务商',
|
noProviders: '未配置搜索服务商',
|
||||||
|
|||||||
@@ -1774,9 +1774,9 @@
|
|||||||
class="w-36"
|
class="w-36"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<!-- Quota summary in collapsed state -->
|
<!-- Quota summary (always visible) -->
|
||||||
<span v-if="!expandedProviders[pIdx]" class="text-xs text-gray-400">
|
<span class="text-xs text-gray-400">
|
||||||
{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit > 0 ? provider.quota_limit : '∞' }}
|
{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit != null && provider.quota_limit > 0 ? provider.quota_limit : '∞' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!expandedProviders[pIdx] && provider.api_key_configured" class="text-xs text-green-500">
|
<span v-if="!expandedProviders[pIdx] && provider.api_key_configured" class="text-xs text-green-500">
|
||||||
{{ t('admin.settings.webSearchEmulation.apiKeyConfigured') }}
|
{{ t('admin.settings.webSearchEmulation.apiKeyConfigured') }}
|
||||||
@@ -1835,7 +1835,7 @@
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaLimit') }}</label>
|
<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" />
|
<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>
|
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.quotaLimitHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1853,7 +1853,7 @@
|
|||||||
<!-- Usage display -->
|
<!-- Usage display -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaUsage') }}:</span>
|
<span class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaUsage') }}:</span>
|
||||||
<div v-if="provider.quota_limit > 0" class="flex-1 rounded-full bg-gray-200 dark:bg-dark-600" style="height: 6px">
|
<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
|
<div
|
||||||
class="h-full rounded-full transition-all"
|
class="h-full rounded-full transition-all"
|
||||||
:class="quotaPercentage(provider) > 90 ? 'bg-red-500' : quotaPercentage(provider) > 70 ? 'bg-yellow-500' : 'bg-green-500'"
|
:class="quotaPercentage(provider) > 90 ? 'bg-red-500' : quotaPercentage(provider) > 70 ? 'bg-yellow-500' : 'bg-green-500'"
|
||||||
@@ -1861,7 +1861,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex-1" />
|
<div v-else class="flex-1" />
|
||||||
<span class="text-xs text-gray-500">{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit > 0 ? provider.quota_limit : '∞' }}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Proxy + Test on same row -->
|
<!-- Proxy + Test on same row -->
|
||||||
@@ -3118,6 +3126,19 @@ function quotaPercentage(provider: WebSearchProviderConfig): number {
|
|||||||
return ((provider.quota_used ?? 0) / provider.quota_limit) * 100
|
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) {
|
async function copyApiKey(idx: number) {
|
||||||
const key = webSearchConfig.providers[idx]?.api_key
|
const key = webSearchConfig.providers[idx]?.api_key
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -3167,9 +3188,16 @@ async function loadWebSearchConfig() {
|
|||||||
|
|
||||||
async function saveWebSearchConfig(): Promise<boolean> {
|
async function saveWebSearchConfig(): Promise<boolean> {
|
||||||
try {
|
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) => ({
|
const providers = webSearchConfig.providers.map((p: WebSearchProviderConfig) => ({
|
||||||
...p,
|
...p,
|
||||||
quota_limit: typeof p.quota_limit === 'number' && p.quota_limit > 0 ? p.quota_limit : 0,
|
quota_limit: Number(p.quota_limit) > 0 ? Number(p.quota_limit) : null,
|
||||||
}))
|
}))
|
||||||
await adminAPI.settings.updateWebSearchEmulationConfig({
|
await adminAPI.settings.updateWebSearchEmulationConfig({
|
||||||
enabled: webSearchConfig.enabled,
|
enabled: webSearchConfig.enabled,
|
||||||
|
|||||||
Reference in New Issue
Block a user