- 顶部展示语音运行时状态:状态标签、LiveKit 版本、下载进度,未就绪时每 5 秒轮询 - 部署失败或不支持平台时提供重新部署入口 - 语音库配置弹窗:启用开关、目录、镜像地址、公网地址、监听地址、信令与媒体端口、启动超时、外部 IP 与节点 IP - 语音日志弹窗:增量拉取、自动刷新开关、手动刷新、最多保留 2000 条 - 房间语音容量设置:参与人数与旁听人数上限 - 补充语音相关 API 与类型定义 - 中英文各补 26 个文案
This commit is contained in:
1 parent
ac38c98203
commit
667c1a0900
4 files changed
+603
-4
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSiteChatPrerequisiteMode,
|
||||
@@ -17,6 +17,15 @@ import {
|
||||
skinSiteChatSettlementOwnerAssign,
|
||||
type SkinSiteChatAuditInfo,
|
||||
type SkinSiteChatUserInfo,
|
||||
skinSiteChatVoiceCapacityUpdate,
|
||||
skinSiteChatVoiceConfig,
|
||||
skinSiteChatVoiceConfigUpdate,
|
||||
skinSiteChatVoiceLogs,
|
||||
skinSiteChatVoiceRuntime,
|
||||
skinSiteChatVoiceRuntimeRetry,
|
||||
type SkinSiteChatVoiceGlobalConfig,
|
||||
type SkinSiteChatVoiceLogEntry,
|
||||
type SkinSiteChatVoiceRuntimeInfo,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
import ChatUserPicker from '../ChatUserPicker.vue'
|
||||
@@ -158,6 +167,110 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Target: {name}',
|
||||
},
|
||||
close: { id: 'app.skin-site.chat.admin.close', defaultMessage: 'Close' },
|
||||
voiceRuntimeTitle: {
|
||||
id: 'app.skin-site.chat.admin.voice-runtime-title',
|
||||
defaultMessage: 'Realtime voice runtime',
|
||||
},
|
||||
voiceLogs: {
|
||||
id: 'app.skin-site.chat.admin.voice-logs',
|
||||
defaultMessage: 'View logs',
|
||||
},
|
||||
voiceConfig: {
|
||||
id: 'app.skin-site.chat.admin.voice-config',
|
||||
defaultMessage: 'Configure runtime',
|
||||
},
|
||||
voiceRetry: {
|
||||
id: 'app.skin-site.chat.admin.voice-retry',
|
||||
defaultMessage: 'Retry deployment',
|
||||
},
|
||||
voiceConfigTitle: {
|
||||
id: 'app.skin-site.chat.admin.voice-config-title',
|
||||
defaultMessage: 'Voice runtime configuration',
|
||||
},
|
||||
voiceLogsTitle: {
|
||||
id: 'app.skin-site.chat.admin.voice-logs-title',
|
||||
defaultMessage: 'LiveKit logs',
|
||||
},
|
||||
voiceCapacityTitle: {
|
||||
id: 'app.skin-site.chat.admin.voice-capacity-title',
|
||||
defaultMessage: 'Voice capacity',
|
||||
},
|
||||
voiceCapacity: {
|
||||
id: 'app.skin-site.chat.admin.voice-capacity',
|
||||
defaultMessage: 'Voice capacity',
|
||||
},
|
||||
participantLimit: {
|
||||
id: 'app.skin-site.chat.admin.participant-limit',
|
||||
defaultMessage: 'Participant limit',
|
||||
},
|
||||
listenerLimit: {
|
||||
id: 'app.skin-site.chat.admin.listener-limit',
|
||||
defaultMessage: 'Listener limit',
|
||||
},
|
||||
voiceCapacityUpdated: {
|
||||
id: 'app.skin-site.chat.admin.voice-capacity-updated',
|
||||
defaultMessage: 'Voice capacity updated.',
|
||||
},
|
||||
voiceEnabled: {
|
||||
id: 'app.skin-site.chat.admin.voice-enabled',
|
||||
defaultMessage: 'Enable realtime voice',
|
||||
},
|
||||
voiceRuntimePath: {
|
||||
id: 'app.skin-site.chat.admin.voice-runtime-path',
|
||||
defaultMessage: 'Runtime directory',
|
||||
},
|
||||
voiceMirror: {
|
||||
id: 'app.skin-site.chat.admin.voice-mirror',
|
||||
defaultMessage: 'Mirror base URL',
|
||||
},
|
||||
voicePublicUrl: {
|
||||
id: 'app.skin-site.chat.admin.voice-public-url',
|
||||
defaultMessage: 'Public URL',
|
||||
},
|
||||
voiceBind: {
|
||||
id: 'app.skin-site.chat.admin.voice-bind',
|
||||
defaultMessage: 'Bind address',
|
||||
},
|
||||
voiceSignalPort: {
|
||||
id: 'app.skin-site.chat.admin.voice-signal-port',
|
||||
defaultMessage: 'Signal port',
|
||||
},
|
||||
voiceRtcTcpPort: {
|
||||
id: 'app.skin-site.chat.admin.voice-rtc-tcp-port',
|
||||
defaultMessage: 'Media TCP port',
|
||||
},
|
||||
voiceRtcUdpPort: {
|
||||
id: 'app.skin-site.chat.admin.voice-rtc-udp-port',
|
||||
defaultMessage: 'Media UDP port',
|
||||
},
|
||||
voiceStartupTimeout: {
|
||||
id: 'app.skin-site.chat.admin.voice-startup-timeout',
|
||||
defaultMessage: 'Startup timeout (s)',
|
||||
},
|
||||
voiceUseExternalIp: {
|
||||
id: 'app.skin-site.chat.admin.voice-use-external-ip',
|
||||
defaultMessage: 'Use external IP',
|
||||
},
|
||||
voiceNodeIp: {
|
||||
id: 'app.skin-site.chat.admin.voice-node-ip',
|
||||
defaultMessage: 'Node IP',
|
||||
},
|
||||
voiceSaved: {
|
||||
id: 'app.skin-site.chat.admin.voice-saved',
|
||||
defaultMessage: 'Voice configuration saved.',
|
||||
},
|
||||
voiceLogsAutoRefresh: {
|
||||
id: 'app.skin-site.chat.admin.voice-logs-auto-refresh',
|
||||
defaultMessage: 'Auto refresh',
|
||||
},
|
||||
voiceLogsRefresh: {
|
||||
id: 'app.skin-site.chat.admin.voice-logs-refresh',
|
||||
defaultMessage: 'Refresh',
|
||||
},
|
||||
voiceLogsEmpty: {
|
||||
id: 'app.skin-site.chat.admin.voice-logs-empty',
|
||||
defaultMessage: 'No logs yet.',
|
||||
},
|
||||
})
|
||||
|
||||
type FormState = {
|
||||
@@ -460,7 +573,217 @@ function closeAudit() {
|
||||
auditItems.value = []
|
||||
}
|
||||
|
||||
onMounted(() => void loadRooms())
|
||||
// ---------- voice runtime ----------
|
||||
const voiceRuntime = ref<SkinSiteChatVoiceRuntimeInfo | null>(null)
|
||||
const runtimeRetrying = ref(false)
|
||||
let runtimePollTimer: number | undefined
|
||||
|
||||
const VOICE_STATE_LABELS: Record<string, string> = {
|
||||
DISABLED: '全局未启用',
|
||||
NOT_INSTALLED: '等待部署',
|
||||
DOWNLOADING: '正在下载',
|
||||
VERIFYING: '正在校验',
|
||||
EXTRACTING: '正在解压',
|
||||
STARTING: '正在启动',
|
||||
READY: '运行正常',
|
||||
UNSUPPORTED_PLATFORM: '当前平台不受支持',
|
||||
}
|
||||
|
||||
const voiceRuntimeLabel = computed(() => {
|
||||
const state = voiceRuntime.value?.state
|
||||
if (!state) return '状态未知'
|
||||
if (state === 'FAILED') return voiceRuntime.value?.message || '部署失败'
|
||||
return VOICE_STATE_LABELS[state] ?? '状态未知'
|
||||
})
|
||||
|
||||
async function loadVoiceRuntime() {
|
||||
try {
|
||||
const res = await skinSiteChatVoiceRuntime()
|
||||
if (res.success) voiceRuntime.value = res.payload
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function retryVoiceRuntime() {
|
||||
if (runtimeRetrying.value) return
|
||||
runtimeRetrying.value = true
|
||||
try {
|
||||
const res = await skinSiteChatVoiceRuntimeRetry()
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
voiceRuntime.value = res.payload
|
||||
addNotification({ type: 'success', title: formatMessage(messages.voiceRetry) })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
runtimeRetrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- voice config ----------
|
||||
const voiceConfigOpen = ref(false)
|
||||
const voiceConfigLoading = ref(false)
|
||||
const voiceConfigSaving = ref(false)
|
||||
const voiceConfigOriginal = ref<SkinSiteChatVoiceGlobalConfig | null>(null)
|
||||
const voiceConfigForm = reactive<SkinSiteChatVoiceGlobalConfig>({
|
||||
enabled: true,
|
||||
runtimePath: './data/voice-runtime',
|
||||
mirrorBaseUrl: '',
|
||||
publicUrl: '',
|
||||
bindAddress: '127.0.0.1',
|
||||
signalPort: 7880,
|
||||
rtcTcpPort: 7881,
|
||||
rtcUdpPort: 7882,
|
||||
startupTimeoutSeconds: 120,
|
||||
useExternalIp: true,
|
||||
nodeIp: '',
|
||||
})
|
||||
|
||||
async function openVoiceConfig() {
|
||||
voiceConfigLoading.value = true
|
||||
try {
|
||||
const res = await skinSiteChatVoiceConfig()
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
voiceConfigOriginal.value = { ...res.payload }
|
||||
Object.assign(voiceConfigForm, res.payload)
|
||||
voiceConfigOpen.value = true
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
voiceConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeVoiceConfig() {
|
||||
voiceConfigOpen.value = false
|
||||
}
|
||||
|
||||
async function saveVoiceConfig() {
|
||||
if (voiceConfigSaving.value) return
|
||||
voiceConfigSaving.value = true
|
||||
try {
|
||||
const res = await skinSiteChatVoiceConfigUpdate({ ...voiceConfigForm })
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
voiceConfigOriginal.value = { ...res.payload.config }
|
||||
Object.assign(voiceConfigForm, res.payload.config)
|
||||
voiceRuntime.value = res.payload.runtime
|
||||
addNotification({ type: 'success', title: formatMessage(messages.voiceSaved) })
|
||||
closeVoiceConfig()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
voiceConfigSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- voice logs ----------
|
||||
const voiceLogsOpen = ref(false)
|
||||
const voiceLogs = ref<SkinSiteChatVoiceLogEntry[]>([])
|
||||
const voiceLogLoading = ref(false)
|
||||
const voiceLogError = ref('')
|
||||
const voiceLogAutoRefresh = ref(true)
|
||||
const voiceLogLatestId = ref(0)
|
||||
let voiceLogPollTimer: number | undefined
|
||||
|
||||
async function openVoiceLogs() {
|
||||
voiceLogs.value = []
|
||||
voiceLogLatestId.value = 0
|
||||
voiceLogError.value = ''
|
||||
voiceLogsOpen.value = true
|
||||
await loadVoiceLogs(true)
|
||||
stopVoiceLogPolling()
|
||||
voiceLogPollTimer = window.setInterval(() => {
|
||||
if (voiceLogAutoRefresh.value && voiceLogsOpen.value) void loadVoiceLogs(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function closeVoiceLogs() {
|
||||
voiceLogsOpen.value = false
|
||||
stopVoiceLogPolling()
|
||||
}
|
||||
|
||||
function stopVoiceLogPolling() {
|
||||
if (voiceLogPollTimer) {
|
||||
window.clearInterval(voiceLogPollTimer)
|
||||
voiceLogPollTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVoiceLogs(manual: boolean) {
|
||||
if (voiceLogLoading.value) return
|
||||
voiceLogLoading.value = true
|
||||
try {
|
||||
const res = await skinSiteChatVoiceLogs(
|
||||
voiceLogs.value.length ? voiceLogLatestId.value : undefined,
|
||||
voiceLogs.value.length ? 500 : 300,
|
||||
)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
voiceLogError.value = ''
|
||||
if (res.payload.entries.length) {
|
||||
voiceLogs.value.push(...res.payload.entries)
|
||||
if (voiceLogs.value.length > 2000) {
|
||||
voiceLogs.value.splice(0, voiceLogs.value.length - 2000)
|
||||
}
|
||||
}
|
||||
voiceLogLatestId.value = res.payload.latestId
|
||||
} catch (error) {
|
||||
voiceLogError.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
voiceLogLoading.value = false
|
||||
void manual
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- voice capacity ----------
|
||||
const capacityRoom = ref<SkinSiteChatRoomInfo | null>(null)
|
||||
const capacitySaving = ref(false)
|
||||
const capacityForm = reactive({ participantLimit: 0, listenerLimit: 0 })
|
||||
|
||||
function openCapacity(room: SkinSiteChatRoomInfo) {
|
||||
capacityRoom.value = room
|
||||
capacityForm.participantLimit = room.voiceParticipantLimit
|
||||
capacityForm.listenerLimit = room.voiceListenerLimit
|
||||
}
|
||||
|
||||
function closeCapacity() {
|
||||
capacityRoom.value = null
|
||||
}
|
||||
|
||||
async function saveCapacity() {
|
||||
if (!capacityRoom.value || capacitySaving.value) return
|
||||
capacitySaving.value = true
|
||||
try {
|
||||
const res = await skinSiteChatVoiceCapacityUpdate(capacityRoom.value.id, {
|
||||
participantLimit: capacityForm.participantLimit,
|
||||
listenerLimit: capacityForm.listenerLimit,
|
||||
})
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
addNotification({ type: 'success', title: formatMessage(messages.voiceCapacityUpdated) })
|
||||
closeCapacity()
|
||||
await loadRooms()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
capacitySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadRooms()
|
||||
void loadVoiceRuntime()
|
||||
runtimePollTimer = window.setInterval(() => {
|
||||
const state = voiceRuntime.value?.state
|
||||
if (state && !['READY', 'DISABLED', 'UNSUPPORTED_PLATFORM'].includes(state)) {
|
||||
void loadVoiceRuntime()
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (runtimePollTimer) window.clearInterval(runtimePollTimer)
|
||||
stopVoiceLogPolling()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -481,6 +804,55 @@ onMounted(() => void loadRooms())
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="voiceRuntime"
|
||||
class="flex flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
|
||||
>
|
||||
<span
|
||||
class="size-2.5 shrink-0 rounded-full"
|
||||
:class="
|
||||
voiceRuntime.state === 'READY'
|
||||
? 'bg-green'
|
||||
: voiceRuntime.state === 'FAILED'
|
||||
? 'bg-red'
|
||||
: 'bg-yellow'
|
||||
"
|
||||
></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.voiceRuntimeTitle) }}
|
||||
</p>
|
||||
<p class="m-0 truncate text-xs text-secondary">
|
||||
{{ voiceRuntimeLabel }} · LiveKit {{ voiceRuntime.version }}
|
||||
<span v-if="voiceRuntime.progress != null"> · {{ voiceRuntime.progress }}%</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="openVoiceLogs"
|
||||
>
|
||||
{{ formatMessage(messages.voiceLogs) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="voiceConfigLoading"
|
||||
@click="openVoiceConfig"
|
||||
>
|
||||
{{ formatMessage(messages.voiceConfig) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="voiceRuntime.state === 'FAILED' || voiceRuntime.state === 'UNSUPPORTED_PLATFORM'"
|
||||
type="button"
|
||||
class="rounded-lg bg-yellow/20 px-3 py-1.5 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="runtimeRetrying || voiceRuntime.state === 'UNSUPPORTED_PLATFORM'"
|
||||
@click="retryVoiceRuntime"
|
||||
>
|
||||
{{ formatMessage(messages.voiceRetry) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary"
|
||||
@@ -585,6 +957,14 @@ onMounted(() => void loadRooms())
|
||||
>
|
||||
{{ formatMessage(messages.audit) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="room.voiceEnabled"
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="openCapacity(room)"
|
||||
>
|
||||
{{ formatMessage(messages.voiceCapacity) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
|
||||
Reference in new issue
Block a user