feat(grok): 网关 Voice TTS/STT/Realtime 与分组音频定价

- 中继 xAI Voice HTTP(/tts /stt /custom-voices)与 Realtime WS(/realtime)
  仅 Grok 分组可用;账号选调度沿用 chat 能力,不依赖创作中心
- Voice URL 固定走 api.x.ai(CLI proxy base 自动回落)
- 分组级 audio_realtime / TTS / STT 单价:schema + migration 218 +
  admin API + GroupsView 表单与 i18n(无创作中心 UI)
This commit is contained in:
IanShaw027
2026-08-07 15:08:39 +08:00
parent a397724f69
commit 99ad01f6de
31 changed files with 1908 additions and 26 deletions
+135
View File
@@ -1483,6 +1483,57 @@
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="createForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.voicePricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.voicePricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="createForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="createForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="createForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
@@ -3123,6 +3174,57 @@
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="editForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.voicePricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.voicePricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="editForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="editForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="editForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
@@ -4806,6 +4908,9 @@ const createForm = reactive({
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -5161,6 +5266,9 @@ const editForm = reactive({
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -5611,6 +5719,9 @@ const closeCreateModal = () => {
createForm.video_price_1080p = null;
createForm.video_model_prices = createVideoModelPricesForm();
createForm.web_search_price_per_call = null;
createForm.audio_realtime_price_per_min = null;
createForm.audio_tts_price_per_million_chars = null;
createForm.audio_stt_price_per_hour = null;
createForm.peak_rate_enabled = false;
createForm.peak_start = "";
createForm.peak_end = "";
@@ -5775,6 +5886,15 @@ const handleCreateGroup = async () => {
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
requestData.audio_realtime_price_per_min = emptyToNull(
requestData.audio_realtime_price_per_min,
);
requestData.audio_tts_price_per_million_chars = emptyToNull(
requestData.audio_tts_price_per_million_chars,
);
requestData.audio_stt_price_per_hour = emptyToNull(
requestData.audio_stt_price_per_hour,
);
requestData.web_search_price_per_call = emptyToNull(
requestData.web_search_price_per_call,
);
@@ -5835,6 +5955,9 @@ const handleEdit = async (group: AdminGroup) => {
group.video_model_prices,
);
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
editForm.audio_realtime_price_per_min = group.audio_realtime_price_per_min ?? null;
editForm.audio_tts_price_per_million_chars = group.audio_tts_price_per_million_chars ?? null;
editForm.audio_stt_price_per_hour = group.audio_stt_price_per_hour ?? null;
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
editForm.peak_start = group.peak_start ?? "";
editForm.peak_end = group.peak_end ?? "";
@@ -5916,6 +6039,9 @@ const closeEditModal = () => {
editForm.video_price_1080p = null;
editForm.video_model_prices = createVideoModelPricesForm();
editForm.web_search_price_per_call = null;
editForm.audio_realtime_price_per_min = null;
editForm.audio_tts_price_per_million_chars = null;
editForm.audio_stt_price_per_hour = null;
resetMessagesDispatchFormState(editForm);
editForm.allow_live = false;
resetModelsListState(editModelsListState);
@@ -6021,6 +6147,15 @@ const handleUpdateGroup = async () => {
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
payload.audio_realtime_price_per_min = emptyPriceToClear(
payload.audio_realtime_price_per_min,
);
payload.audio_tts_price_per_million_chars = emptyPriceToClear(
payload.audio_tts_price_per_million_chars,
);
payload.audio_stt_price_per_hour = emptyPriceToClear(
payload.audio_stt_price_per_hour,
);
payload.web_search_price_per_call = emptyPriceToClear(
payload.web_search_price_per_call,
);