AccountTrafficControls.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. <template>
  2. <details :open="embedded" class="rounded-xl border border-gray-200 p-4 dark:border-dark-700" data-testid="account-traffic-controls" @toggle="onToggle">
  3. <summary class="cursor-pointer text-sm font-medium">可选流量控制 · 严格 RPM / 自适应并发</summary>
  4. <p class="mt-3 text-xs leading-relaxed text-gray-500">两个功能独立开关,默认关闭。启用后在账号固定并发上限内执行,关闭后恢复固定上限;不会改动身份或 TLS 策略。</p>
  5. <p v-if="platform === 'grok'" class="mt-2 text-xs leading-relaxed text-amber-700 dark:text-amber-300">Grok 实时语音目前只能按会话观测。开启严格 RPM 或自动并发后,实时语音连接会被明确拒绝;HTTP 语音接口仍支持这些限制。</p>
  6. <p v-if="loading" class="mt-3 text-sm text-gray-500">正在读取配置…</p>
  7. <div v-if="error" role="alert" class="mt-3 text-sm text-red-600">{{ error }} <button type="button" class="underline" @click="load">重试</button></div>
  8. <fieldset v-if="ready || embedded" :disabled="saving || disabled" class="mt-4 space-y-4" @input="saved = false" @change="saved = false">
  9. <div class="rounded-lg bg-gray-50 p-3 text-xs dark:bg-dark-900">
  10. <p class="leading-relaxed">起步建议:RPM 60、突发 5、最低并发 1;60 秒内累计 3 次上游 429 / 5xx 后建议降速,稳定 60 秒且至少成功 3 次后逐步恢复。这是可修改的起点,不代表上游账号的实际额度。</p>
  11. <button type="button" class="mt-2 text-primary-600 underline" data-testid="traffic-recommended" @click="useRecommended">填入建议参数</button>
  12. <span class="ml-2 text-gray-500">只填数值,保留功能开关和运行方式;点击保存后生效</span>
  13. </div>
  14. <label class="flex items-center gap-2 text-sm"><input :form="embedded ? undefined : detachedFormId" v-model="policy.strict_rpm_enabled" type="checkbox" data-testid="strict-rpm-toggle" />启用严格 RPM</label>
  15. <div v-if="policy.strict_rpm_enabled" class="grid gap-3 sm:grid-cols-2">
  16. <label class="text-sm">每分钟硬上限<input :form="embedded ? undefined : detachedFormId" v-model.number="policy.rpm" class="input mt-1" type="number" min="1" max="60000" /></label>
  17. <label class="text-sm">瞬时突发额度<input :form="embedded ? undefined : detachedFormId" v-model.number="policy.burst" class="input mt-1" type="number" min="1" :max="policy.rpm" /></label>
  18. <p class="text-xs text-gray-500 sm:col-span-2">滚动 60 秒内不超过硬上限,突发额度包含在上限内。模型请求与重试共享预算;达到限制时返回可重试提示。</p>
  19. </div>
  20. <label class="flex items-center gap-2 text-sm"><input :form="embedded ? undefined : detachedFormId" v-model="policy.adaptive_enabled" type="checkbox" data-testid="adaptive-toggle" />启用自适应并发</label>
  21. <div v-if="policy.adaptive_enabled" class="grid gap-3 sm:grid-cols-2">
  22. <label class="text-sm">运行方式<select :form="embedded ? undefined : detachedFormId" v-model="policy.adaptive_mode" class="input mt-1"><option value="observe">只显示建议,不自动调整</option><option value="automatic">自动降速、缓慢恢复</option></select></label>
  23. <label class="text-sm">最低并发<input :form="embedded ? undefined : detachedFormId" v-model.number="policy.min_concurrency" class="input mt-1" type="number" min="1" :max="hardLimit > 0 ? hardLimit : 10000" /></label>
  24. <label class="text-sm">触发失败次数<input :form="embedded ? undefined : detachedFormId" v-model.number="policy.failure_threshold" class="input mt-1" type="number" min="1" max="100" /></label>
  25. <label class="text-sm">失败统计窗口(秒)<input :form="embedded ? undefined : detachedFormId" v-model.number="policy.failure_window_seconds" class="input mt-1" type="number" min="10" max="3600" /></label>
  26. <label class="text-sm">稳定恢复周期(秒)<input :form="embedded ? undefined : detachedFormId" v-model.number="policy.recovery_seconds" class="input mt-1" type="number" min="10" max="3600" /></label>
  27. <p class="text-xs text-gray-500 sm:col-span-2">上游 429 / 5xx 达到阈值时建议将并发减半;稳定一段时间且至少完成 3 次成功响应后,每周期恢复 1 个名额。{{ embedded ? '本次设置的并发上限' : '当前固定上限' }}:{{ hardLimit > 0 ? hardLimit : '未设置,请先设置账号并发' }}。以后调低固定上限时,运行中的最低并发也会随之限制。</p>
  28. </div>
  29. <p v-if="embedded" class="text-xs text-gray-500">与账号其他设置一起保存,关闭开关即可停用该功能。</p>
  30. <div v-else class="flex flex-wrap items-center gap-3"><button type="button" class="btn btn-secondary" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存流量控制' }}</button><span v-if="saved" class="text-xs text-emerald-700">已保存</span><span class="text-xs text-gray-500">此区域独立保存</span></div>
  31. </fieldset>
  32. <div v-if="ready" class="mt-4 rounded-lg bg-gray-50 p-3 text-xs dark:bg-dark-900">
  33. <div class="flex items-center justify-between"><span>已保存配置的运行观测</span><button type="button" class="text-primary-600" :disabled="loading || saving || refreshing" @click="refreshState">{{ refreshing ? '刷新中…' : '刷新状态' }}</button></div>
  34. <p v-if="!stateAvailable" class="mt-2 text-gray-500">状态暂不可用,配置仍可查看。严格 RPM 或自动并发控制无法确认准入时会暂停新请求;仅建议模式继续使用固定并发。</p>
  35. <p v-else-if="!activePolicy.strict_rpm_enabled && !activePolicy.adaptive_enabled" class="mt-2 text-gray-500">当前未启用流量控制,不主动采集这组观测。</p>
  36. <dl v-else-if="state" class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
  37. <div><dt>有效并发上限</dt><dd>{{ state.effective_concurrency || '不限' }}</dd></div><div><dt>建议并发</dt><dd>{{ state.recommended_concurrency || '—' }}</dd></div><div><dt>执行中</dt><dd>{{ state.in_flight }}</dd></div>
  38. <div><dt>最近一分钟请求</dt><dd>{{ state.requests_last_minute }}</dd></div><div><dt>上游 429 / 5xx</dt><dd>{{ state.upstream_429 }} / {{ state.upstream_5xx }}</dd></div><div><dt>本地 RPM / 并发拒绝</dt><dd>{{ state.rejected_rpm }} / {{ state.rejected_concurrency }}</dd></div>
  39. <div><dt>平均响应耗时</dt><dd>{{ (state.average_duration_ms / 1000).toFixed(2) }} 秒</dd></div>
  40. </dl>
  41. <p v-if="stateAvailable && (activePolicy.strict_rpm_enabled || activePolicy.adaptive_enabled)" class="mt-2 text-gray-500">累计数来自当前缓存观测周期,长期无流量或缓存重置后会重新累计。</p>
  42. </div>
  43. </details>
  44. </template>
  45. <script setup lang="ts">
  46. import { computed, getCurrentInstance, onUnmounted, ref, watch } from 'vue'
  47. import { accountTrafficAPI, defaultTrafficPolicy, normalizeTrafficDraft, trafficPolicyError, type AccountTrafficPolicy, type AccountTrafficState } from '@/api/admin/accountTraffic'
  48. import { extractApiErrorMessage } from '@/utils/apiError'
  49. const props = defineProps<{ accountId: number; platform?: string; embedded?: boolean; modelValue?: AccountTrafficPolicy; hardLimit?: number; disabled?: boolean }>()
  50. const emit = defineEmits<{ saved: [policy: AccountTrafficPolicy]; 'update:modelValue': [policy: AccountTrafficPolicy] }>()
  51. const detachedFormId = `account-traffic-${getCurrentInstance()?.uid}`
  52. const policy = ref(defaultTrafficPolicy()), activePolicy = ref(defaultTrafficPolicy()), state = ref<AccountTrafficState | null>(null)
  53. const loading = ref(false), saving = ref(false), refreshing = ref(false), ready = ref(false), saved = ref(false), error = ref(''), stateAvailable = ref(false), storedHardLimit = ref(0), expanded = ref(false)
  54. const hardLimit = computed(() => props.hardLimit ?? storedHardLimit.value)
  55. const samePolicy = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
  56. watch(() => props.modelValue, value => { if (value && !samePolicy(value, policy.value)) policy.value = { ...value } }, { immediate: true, deep: true })
  57. watch(policy, value => { if (props.embedded && !samePolicy(value, props.modelValue)) emit('update:modelValue', { ...value }) }, { deep: true })
  58. let version = 0, controller: AbortController | undefined
  59. function useRecommended() {
  60. policy.value = { ...defaultTrafficPolicy(), strict_rpm_enabled: policy.value.strict_rpm_enabled, adaptive_enabled: policy.value.adaptive_enabled, adaptive_mode: policy.value.adaptive_mode }
  61. saved.value = false; error.value = ''
  62. }
  63. function onToggle(event: Event) { expanded.value = (event.target as HTMLDetailsElement).open; if (expanded.value && !ready.value && !loading.value) void load() }
  64. async function load() { await readState(!props.embedded) }
  65. async function readState(replaceDraft: boolean) {
  66. if (saving.value) return
  67. const current = ++version; controller?.abort(); controller = new AbortController(); loading.value = !ready.value; refreshing.value = true; error.value = ''
  68. try {
  69. const data = await accountTrafficAPI.get(props.accountId, controller.signal)
  70. if (current !== version) return
  71. if (replaceDraft) policy.value = { ...data.policy }
  72. activePolicy.value = { ...data.policy }; state.value = data.state ?? null; stateAvailable.value = data.state_available; storedHardLimit.value = data.hard_limit; ready.value = true
  73. } catch (err) { if (current === version) error.value = extractApiErrorMessage(err, '无法读取流量控制配置') }
  74. finally { if (current === version) { loading.value = false; refreshing.value = false } }
  75. }
  76. async function refreshState() { await readState(false) }
  77. function prepareForSave(): AccountTrafficPolicy | null {
  78. const normalized = normalizeTrafficDraft(policy.value)
  79. error.value = trafficPolicyError(normalized, hardLimit.value)
  80. if (error.value) return null
  81. policy.value = normalized
  82. return { ...normalized }
  83. }
  84. async function save() {
  85. if (saving.value || props.embedded) return
  86. const snapshot = prepareForSave()
  87. if (!snapshot) return
  88. const current = ++version, id = props.accountId
  89. controller?.abort(); refreshing.value = false; loading.value = false
  90. saving.value = true; saved.value = false
  91. try {
  92. const data = await accountTrafficAPI.save(id, snapshot)
  93. if (current !== version) return
  94. policy.value = { ...data.policy }; activePolicy.value = { ...data.policy }; saved.value = true; stateAvailable.value = data.state_available
  95. emit('saved', { ...data.policy })
  96. if (!data.state_available) error.value = '配置已保存,运行状态同步暂不可用,请稍后刷新状态'
  97. else { saving.value = false; await refreshState() }
  98. } catch (err) { if (current === version) error.value = extractApiErrorMessage(err, '流量控制保存失败') }
  99. finally { saving.value = false }
  100. }
  101. watch(() => props.accountId, () => { version++; controller?.abort(); ready.value = false; loading.value = false; refreshing.value = false; saved.value = false; state.value = null; if (expanded.value) void load() })
  102. onUnmounted(() => { version++; controller?.abort() })
  103. defineExpose({ prepareForSave })
  104. </script>