feat: 更新配置和组件以支持构建时间显示功能, 修复读弹幕的问题

- 在vite.config.mts中添加__BUILD_TIME__常量以记录构建时间
- 在AboutView.vue中显示构建时间及其相对时间
- 在ReadDanmaku.vue中重构语音合成状态管理,优化礼物合并逻辑
- 更新相关类型定义,增强代码可读性和可维护性
This commit is contained in:
Megghy
2025-06-25 10:55:27 +08:00
parent a8b4e2fbad
commit f57c856c3b
5 changed files with 411 additions and 202 deletions

View File

@@ -553,6 +553,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -1 +1 @@
declare const __BUILD_TIME__: string;

View File

@@ -1,7 +1,25 @@
<script setup lang="ts">
import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTimelineItem, NTag, NIcon } from 'naive-ui'
import { h } from 'vue'
import { h, computed } from 'vue'
import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/ionicons5'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
// 获取编译时间
const buildTime = computed(() => {
try {
const buildDate = new Date(__BUILD_TIME__)
return {
date: buildDate.toLocaleString('zh-CN'),
relative: formatDistanceToNow(buildDate, { addSuffix: true, locale: zhCN })
}
} catch {
return {
date: '未知',
relative: '未知'
}
}
})
</script>
<template>
<NLayoutContent style="height: 100vh; padding: 20px 0;">
@@ -19,6 +37,9 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io
<div style="font-size: 22px; font-weight: bold; padding: 8px 0;">
关于
</div>
<div style="font-size: 13px; color: #666; margin-top: 4px; padding-bottom: 8px;">
构建时间: {{ buildTime.date }} ({{ buildTime.relative }})
</div>
</template>
<NText>
一个兴趣使然的网站

View File

@@ -38,7 +38,7 @@ import {
NUl,
useMessage,
} from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { clearInterval, setInterval } from 'worker-timers'
@@ -57,12 +57,12 @@ type SpeechSettings = {
enterTemplate: string
voiceType: 'local' | 'api'
voiceAPISchemeType: 'http' | 'https'
voiceAPI?: string
voiceAPI: string
splitText: boolean
useAPIDirectly: boolean
combineGiftDelay?: number
combineGiftDelay: number | undefined
}
type SpeechInfo = {
volume: number
pitch: number
@@ -70,6 +70,22 @@ type SpeechInfo = {
voice: string
}
type SpeechState = {
isSpeaking: boolean
speakingText: string
isApiAudioLoading: boolean
apiAudioSrc: string
canSpeech: boolean
}
const speechState = reactive<SpeechState>({
isSpeaking: false,
speakingText: '',
isApiAudioLoading: false,
apiAudioSrc: '',
canSpeech: false,
})
const accountInfo = useAccount()
const message = useMessage()
const route = useRoute()
@@ -91,7 +107,6 @@ const settings = useStorage<SpeechSettings>('Setting.Speech', {
voiceAPI: 'voice.vtsuru.live/voice/bert-vits2?text={{text}}&id=1&format=mp3&streaming=true',
useAPIDirectly: false,
splitText: false,
combineGiftDelay: 2,
})
const speechSynthesisInfo = ref<{
@@ -121,16 +136,15 @@ const voiceOptions = computed(() => {
.DistinctBy((v) => v.value)
.ToArray()
})
const isSpeaking = ref(false)
const speakingText = ref('')
const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([])
const giftCombineMap = new Map<string, number>() // 用于快速查找礼物合并项
const MAX_QUEUE_SIZE = 50 // 最大队列长度限制
const isVtsuruVoiceAPI = computed(() => {
return (
settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
)
})
const canSpeech = ref(false)
const readedDanmaku = ref(0)
const templateConstants = {
@@ -175,6 +189,22 @@ const templateConstants = {
regex: /\{\s*gift_name\s*\}/gi,
},
}
/**
* 根据舰长等级数字返回对应的中文名称
* @param guardLevel 舰长等级
*/
function getGuardLevelName(guardLevel: number): string {
switch (guardLevel) {
case 1: return '总督'
case 2: return '提督'
case 3: return '舰长'
default: return ''
}
}
/**
* 强制朗读指定事件, 会中断当前朗读并插队到最前面
* @param data 事件数据
*/
function forceSpeak(data: EventModel) {
cancelSpeech()
@@ -188,21 +218,55 @@ function forceSpeak(data: EventModel) {
})
}
async function speak() {
if (isSpeaking.value || speakQueue.value.length == 0) {
if (speechState.isSpeaking || speakQueue.value.length == 0) {
return
}
const data = speakQueue.value[0]
if (
data.data.type == EventDataTypes.Gift &&
data.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000
) {
// 寻找可以立即播放的事件(优先非等待合并的礼物)
let targetIndex = -1
const now = Date.now()
const combineDelay = (settings.value.combineGiftDelay ?? 0) * 1000
for (let i = 0; i < speakQueue.value.length; i++) {
const item = speakQueue.value[i]
// 如果是礼物且还在等待合并期间,跳过
if (item.data.type == EventDataTypes.Gift &&
combineDelay > 0 &&
item.updateAt > now - combineDelay) {
continue
}
// 找到第一个可以播放的事件
targetIndex = i
break
}
// 如果没有找到可播放的事件,直接返回
if (targetIndex === -1) {
return
}
let text = getTextFromDanmaku(speakQueue.value.shift()?.data)
// 获取要播放的事件并从队列中移除
const targetItem = speakQueue.value.splice(targetIndex, 1)[0]
// 如果移除的不是礼物事件,需要更新礼物合并映射的索引
if (targetItem.data.type !== EventDataTypes.Gift) {
// 重建礼物合并映射,因为索引可能发生变化
giftCombineMap.clear()
speakQueue.value.forEach((item, index) => {
if (item.data.type === EventDataTypes.Gift) {
const giftKey = `${item.data.uid}-${item.data.msg}`
giftCombineMap.set(giftKey, index)
}
})
}
let text = getTextFromDanmaku(targetItem.data)
if (text) {
isSpeaking.value = true
speechState.isSpeaking = true
readedDanmaku.value++
speakingText.value = text
speechState.speakingText = text
if (checkTimer) {
clearInterval(checkTimer)
}
@@ -216,7 +280,7 @@ async function speak() {
text = settings.value.splitText ? insertSpaces(text) : text
speakFromAPI(text)
}
console.log(`[TTS] 正在朗读: ${text}`)
console.log(`[TTS] 正在朗读: ${text} ${targetIndex > 0 ? '(跳过等待合并的礼物)' : ''}`)
}
}
function insertSpaces(sentence: string) {
@@ -234,6 +298,10 @@ function insertSpaces(sentence: string) {
return sentence
}
let checkTimer: number | undefined
/**
* 使用浏览器本地语音合成功能朗读
* @param text 要朗读的文本
*/
function speakDirect(text: string) {
try {
const synth = window.speechSynthesis
@@ -266,109 +334,163 @@ function speakDirect(text: string) {
}
} catch (err) {
console.log(err)
// 如果本地语音合成失败,确保清理定时器
cancelSpeech()
}
}
const apiAudio = ref<HTMLAudioElement>()
const isApiAudioLoading = ref(false)
const apiAudioSrc = ref('')
const splitter = new GraphemeSplitter()
function speakFromAPI(text: string) {
/**
* 构建API请求URL
* @param text 要朗读的文本
*/
function buildApiUrl(text: string): string | null {
if (!settings.value.voiceAPI) {
message.error('未设置语音API')
return
return null
}
isSpeaking.value = true
isApiAudioLoading.value = true
let url = `${settings.value.voiceAPISchemeType == 'https' ? 'https' : (settings.value.useAPIDirectly ? '' : FETCH_API) + 'http'}://${settings.value.voiceAPI
.trim()
.replace(/^(?:https?:\/\/)/, '')
.replace(/\{\{\s*text\s*\}\}/, encodeURIComponent(text))}`
let tempURL: URL
const scheme =
settings.value.voiceAPISchemeType === 'https'
? 'https://'
: settings.value.useAPIDirectly
? 'http://'
: `${FETCH_API}http://`
const url = `${scheme}${settings.value.voiceAPI.trim().replace(/^(?:https?:\/\/)/, '')}`.replace(
/\{\{\s*text\s*\}\}/,
encodeURIComponent(text),
)
try {
tempURL = new URL(url)
const tempURL = new URL(url)
if (isVtsuruVoiceAPI.value) {
tempURL.searchParams.set('vtsuruId', accountInfo.value?.id.toString() ?? '-1')
if (splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 100) {
message.error(`本站提供的测试接口字数不允许超过 100 字. 内容: [${tempURL.searchParams.get('text')}]`)
return null
}
}
return tempURL.toString()
} catch (err) {
console.log(err)
message.error('无效的API地址: ' + url)
return null
}
}
/**
* 从API获取语音并播放
* @param text 要朗读的文本
*/
function speakFromAPI(text: string) {
const url = buildApiUrl(text)
if (!url) {
cancelSpeech()
return
}
if (isVtsuruVoiceAPI.value) {
tempURL.searchParams.set('vtsuruId', accountInfo.value?.id.toString() ?? '-1')
url = tempURL.toString()
}
if (isVtsuruVoiceAPI.value && splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 50) {
message.error('本站提供的测试接口字数不允许超过 100 字. 内容: [' + tempURL.searchParams.get('text') + ']')
cancelSpeech()
return
}
apiAudioSrc.value = url
speechState.isSpeaking = true
speechState.isApiAudioLoading = true
speechState.apiAudioSrc = url
nextTick(() => {
//apiAudio.value?.load()
apiAudio.value?.play().catch((err) => {
if (apiAudio.value) {
// 设置预加载
apiAudio.value.preload = 'auto'
// 播放音频
const playPromise = apiAudio.value.play()
if (playPromise) {
playPromise.catch((err) => {
if (err.toString().startsWith('AbortError')) {
return
}
console.log(err)
console.log(err)
message.error('无法播放语音:' + err)
console.error('[speakFromAPI] 音频播放失败:', err)
message.error('无法播放语音: ' + err.message)
cancelSpeech()
})
}
}
})
}
function onAPIError(e: Event) {
if (!apiAudioSrc.value) return
if (!speechState.apiAudioSrc) return
message.error('音频加载失败, 请检查API是否可用以及网络连接')
cancelSpeech()
}
/**
* 取消/停止当前所有朗读
*/
function cancelSpeech() {
isSpeaking.value = false
speechState.isSpeaking = false
if (checkTimer) {
clearInterval(checkTimer)
checkTimer = undefined
}
isApiAudioLoading.value = false
speechState.isApiAudioLoading = false
pauseAPI()
EasySpeech.cancel()
speakingText.value = ''
speechState.speakingText = ''
}
function pauseAPI() {
if (!apiAudio.value?.paused) {
apiAudio.value?.pause()
if (apiAudio.value && !apiAudio.value.paused) {
apiAudio.value.pause()
}
}
/**
* 接收到事件, 添加到朗读队列
* @param data 事件数据
*/
function onGetEvent(data: EventModel) {
if (!canSpeech.value) {
if (!speechState.canSpeech) {
return
}
if (data.type == EventDataTypes.Message && (data.emoji || /^(?:\[\w+\])+$/.test(data.msg))) {
// 不支持表情
// 不朗读纯表情/图片弹幕
return
}
if (data.type == EventDataTypes.Enter && !settings.value.enterTemplate) {
return
}
if (data.type == EventDataTypes.Gift) {
const exist = speakQueue.value.find(
(v) =>
v.data.type == EventDataTypes.Gift &&
v.data.uid == data.uid &&
v.data.msg == data.msg &&
v.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000,
)
if (exist) {
// 礼物合并逻辑 - 使用Map优化性能
if (data.type == EventDataTypes.Gift && settings.value.combineGiftDelay) {
const giftKey = `${data.uid}-${data.msg}` // 用户ID和礼物名称组成的唯一键
const existIndex = giftCombineMap.get(giftKey)
if (existIndex !== undefined && existIndex < speakQueue.value.length) {
const exist = speakQueue.value[existIndex]
if (exist &&
exist.data.type == EventDataTypes.Gift &&
exist.updateAt > Date.now() - (settings.value.combineGiftDelay * 1000)) {
// 更新现有礼物数据
exist.updateAt = Date.now()
exist.data.num += data.num
exist.data.price += data.price
exist.combineCount ??= 0
exist.combineCount += data.num
exist.combineCount = (exist.combineCount ?? 0) + data.num
console.log(
`[TTS] ${data.uname} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`,
)
return
}
}
// 添加新的礼物到队列
const newIndex = speakQueue.value.length
giftCombineMap.set(giftKey, newIndex)
// 清理过期的Map条目
setTimeout(() => {
if (giftCombineMap.get(giftKey) === newIndex) {
giftCombineMap.delete(giftKey)
}
}, (settings.value.combineGiftDelay + 1) * 1000)
}
speakQueue.value.push({
data,
updateAt: data.type == EventDataTypes.Gift ? Date.now() : 0,
updateAt: data.type == EventDataTypes.Gift ? Date.now() : 0, // 礼物事件记录时间用于合并
})
}
function getTextFromDanmaku(data: EventModel | undefined) {
@@ -418,13 +540,13 @@ function getTextFromDanmaku(data: EventModel | undefined) {
.replace(templateConstants.message.regex, data.msg)
.replace(
templateConstants.guard_level.regex,
data.guard_level == 1 ? '总督' : data.guard_level == 2 ? '提督' : data.guard_level == 3 ? '舰长' : '',
getGuardLevelName(data.guard_level),
)
.replace(templateConstants.fans_medal_level.regex, data.fans_medal_level.toString())
.trim()
if (data.type === EventDataTypes.Message) {
text = text.replace(/\[.*?\]/g, ' ') //删除表情
text = text.replace(/\[.*?\]/g, ' ') //删除 [表情], B站的表情是 [生气了] 这样的格式
} else if (data.type === EventDataTypes.Gift) {
text = text.replace(templateConstants.gift_name.regex, data.msg)
} else if (data.type === EventDataTypes.Guard) {
@@ -432,7 +554,7 @@ function getTextFromDanmaku(data: EventModel | undefined) {
}
text = fullWidthToHalfWidth(text)
.replace(/[^0-9a-zA-Z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF ,.:'"\s]/gi, '')
.normalize('NFKC') //过滤无效字符, 全角转半角
.normalize('NFKC')
return text
}
function fullWidthToHalfWidth(str: string) {
@@ -447,22 +569,28 @@ function fullWidthToHalfWidth(str: string) {
return result
}
function startSpeech() {
canSpeech.value = true
speechState.canSpeech = true
message.success('服务已启动')
}
function stopSpeech() {
canSpeech.value = false
speechState.canSpeech = false
message.success('已停止监听')
}
async function uploadConfig() {
try {
const result = await UploadConfig('Speech', settings.value)
if (result) {
message.success('已保存至服务器')
} else {
message.error('保存失败')
}
} catch (error) {
console.error('[uploadConfig] 上传配置失败:', error)
message.error('保存失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
async function downloadConfig() {
try {
const result = await DownloadConfig<SpeechSettings>('Speech')
if (result.status === 'success' && result.data) {
settings.value = result.data
@@ -472,31 +600,20 @@ async function downloadConfig() {
} else {
message.error('获取失败: ' + result.msg)
}
} catch (error) {
console.error('[downloadConfig] 下载配置失败:', error)
message.error('获取失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
function test(type: EventDataTypes) {
switch (type) {
case EventDataTypes.Message:
forceSpeak({
type: EventDataTypes.Message,
uname: accountInfo.value?.name ?? '测试用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '测试弹幕',
price: 0,
num: 0,
time: Date.now(),
guard_level: 0,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
break
case EventDataTypes.Enter:
forceSpeak({
type: EventDataTypes.Enter,
}
/**
* 创建测试事件数据
* @param type 事件类型
* @param overrides 覆盖默认值的对象
*/
function createTestEventData(type: EventDataTypes, overrides: Partial<EventModel>): EventModel {
const baseData = {
type,
uname: accountInfo.value?.name ?? '测试用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '',
@@ -511,75 +628,122 @@ function test(type: EventDataTypes) {
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
}
return { ...baseData, ...overrides }
}
/**
* 测试不同类型的事件
* @param type 事件类型
*/
function test(type: EventDataTypes) {
let testData: EventModel
switch (type) {
case EventDataTypes.Message:
testData = createTestEventData(EventDataTypes.Message, { msg: '测试弹幕' })
break
case EventDataTypes.Enter:
testData = createTestEventData(EventDataTypes.Enter, {})
break
case EventDataTypes.SC:
forceSpeak({
type: EventDataTypes.SC,
uname: accountInfo.value?.name ?? '测试用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '测试留言',
price: 30,
num: 1,
time: Date.now(),
guard_level: 0,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
testData = createTestEventData(EventDataTypes.SC, { msg: '测试留言', price: 30, num: 1 })
break
case EventDataTypes.Guard:
forceSpeak({
type: EventDataTypes.Guard,
uname: accountInfo.value?.name ?? '测试用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '舰长',
price: 0,
num: 1,
time: Date.now(),
guard_level: 3,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
testData = createTestEventData(EventDataTypes.Guard, { msg: '舰长', num: 1, guard_level: 3 })
break
case EventDataTypes.Gift:
forceSpeak({
type: EventDataTypes.Gift,
uname: accountInfo.value?.name ?? '测试用户',
uid: accountInfo.value?.biliId ?? 0,
msg: '测试礼物',
price: 5,
num: 5,
time: Date.now(),
guard_level: 0,
fans_medal_level: 1,
fans_medal_name: '',
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
testData = createTestEventData(EventDataTypes.Gift, { msg: '测试礼物', price: 5, num: 5 })
break
default:
return
}
// 如果正在监听,加入队列;否则直接播放
if (speechState.canSpeech) {
onGetEvent(testData)
} else {
forceSpeak(testData)
}
}
function testAPI() {
// 直接测试API不受监听状态影响
speakFromAPI('这是一条测试弹幕')
}
/**
* 清理过期或无效的队列项
*/
function cleanupQueue() {
const now = Date.now()
const validItems: typeof speakQueue.value = []
speakQueue.value.forEach((item, index) => {
// 保留非礼物事件或未过期的礼物事件
if (item.data.type !== EventDataTypes.Gift ||
item.updateAt > now - (settings.value.combineGiftDelay ?? 0) * 1000) {
validItems.push(item)
} else {
// 从Map中移除过期的礼物项
const giftKey = `${item.data.uid}-${item.data.msg}`
if (giftCombineMap.get(giftKey) === index) {
giftCombineMap.delete(giftKey)
}
}
})
// 如果队列过长,保留最新的项目
if (validItems.length > MAX_QUEUE_SIZE) {
const removedItems = validItems.splice(0, validItems.length - MAX_QUEUE_SIZE)
console.warn(`[TTS] 队列过长,已移除 ${removedItems.length} 个旧项目`)
}
speakQueue.value = validItems
// 重建Map索引
giftCombineMap.clear()
speakQueue.value.forEach((item, index) => {
if (item.data.type === EventDataTypes.Gift) {
const giftKey = `${item.data.uid}-${item.data.msg}`
giftCombineMap.set(giftKey, index)
}
})
}
let speechQueueTimer: number
onMounted(() => {
EasySpeech.init({ maxTimeout: 5000, interval: 250 })
speechSynthesisInfo.value = EasySpeech.detect()
// 自动选择默认语音
const checkAndSetDefaultVoice = () => {
const voices = EasySpeech.voices()
if (voices.length > 0 && !settings.value.speechInfo.voice) {
// 优先选择中文语音
const chineseVoice = voices.find(v => v.lang.startsWith('zh'))
settings.value.speechInfo.voice = chineseVoice?.name || voices[0].name
console.log(`[TTS] 自动选择默认语音: ${settings.value.speechInfo.voice}`)
}
}
// 立即检查一次
checkAndSetDefaultVoice()
// 如果语音列表还没加载完成,等待加载完成后再检查
if (EasySpeech.voices().length === 0) {
const voiceCheckTimer = setInterval(() => {
if (EasySpeech.voices().length > 0) {
checkAndSetDefaultVoice()
clearInterval(voiceCheckTimer)
}
}, 100)
// 10秒后停止检查避免无限循环
setTimeout(() => clearInterval(voiceCheckTimer), 10000)
}
// 语音播放队列处理
speechQueueTimer = setInterval(() => {
speak()
}, 250)
@@ -591,12 +755,29 @@ onMounted(() => {
client.onEvent('enter', onGetEvent)
})
onUnmounted(() => {
// 清理队列定时器
if (speechQueueTimer) {
clearInterval(speechQueueTimer)
}
// 清理语音超时定时器
if (checkTimer) {
clearInterval(checkTimer)
checkTimer = undefined
}
// 停止当前语音播放
cancelSpeech()
// 清理事件监听器
client.offEvent('danmaku', onGetEvent)
client.offEvent('sc', onGetEvent)
client.offEvent('guard', onGetEvent)
client.offEvent('gift', onGetEvent)
client.offEvent('enter', onGetEvent)
// 清理礼物合并映射
giftCombineMap.clear()
})
</script>
@@ -662,13 +843,13 @@ onUnmounted(() => {
<br>
<NSpace align="center">
<NButton
:type="canSpeech ? 'error' : 'primary'"
:type="speechState.canSpeech ? 'error' : 'primary'"
data-umami-event="Use TTS"
:data-umami-event-uid="accountInfo?.id"
size="large"
@click="canSpeech ? stopSpeech() : startSpeech()"
@click="speechState.canSpeech ? stopSpeech() : startSpeech()"
>
{{ canSpeech ? '停止监听' : '开始监听' }}
{{ speechState.canSpeech ? '停止监听' : '开始监听' }}
</NButton>
<NButton
type="primary"
@@ -693,13 +874,13 @@ onUnmounted(() => {
这将覆盖当前设置, 确定?
</NPopconfirm>
</NSpace>
<template v-if="canSpeech">
<template v-if="speechState.canSpeech">
<NDivider> 状态 </NDivider>
<NSpace
vertical
align="center"
>
<NTooltip v-if="settings.voiceType == 'api' && isApiAudioLoading">
<NTooltip v-if="settings.voiceType == 'api' && speechState.isApiAudioLoading">
<template #trigger>
<NButton
circle
@@ -716,19 +897,19 @@ onUnmounted(() => {
<template #trigger>
<NButton
circle
:disabled="!isSpeaking"
:style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`"
:disabled="!speechState.isSpeaking"
:style="`animation: ${speechState.isSpeaking ? 'animated-border 2.5s infinite;' : ''}`"
@click="cancelSpeech"
>
<template #icon>
<NIcon
:component="Mic24Filled"
:color="isSpeaking ? 'green' : 'gray'"
:color="speechState.isSpeaking ? 'green' : 'gray'"
/>
</template>
</NButton>
</template>
{{ isSpeaking ? '取消朗读' : '未朗读' }}
{{ speechState.isSpeaking ? `正在朗读: ${speechState.speakingText}` : '未朗读' }}
</NTooltip>
<NText depth="3">
队列: {{ speakQueue.length }}
@@ -854,7 +1035,10 @@ onUnmounted(() => {
<NSelect
v-model:value="settings.speechInfo.voice"
:options="voiceOptions"
:fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })"
:fallback-option="() => ({
label: settings.speechInfo.voice ? `已选择: ${settings.speechInfo.voice}` : '未选择, 将使用默认语音',
value: settings.speechInfo.voice || ''
})"
/>
<span style="width: 100%">
<NText> 音量 </NText>
@@ -976,7 +1160,7 @@ onUnmounted(() => {
/>
<NButton
type="info"
:loading="isApiAudioLoading"
:loading="speechState.isApiAudioLoading"
@click="testAPI"
>
测试
@@ -1013,10 +1197,10 @@ onUnmounted(() => {
</NSpace>
<audio
ref="apiAudio"
:src="apiAudioSrc"
:src="speechState.apiAudioSrc"
:volume="settings.speechInfo.volume"
@ended="cancelSpeech"
@canplay="isApiAudioLoading = false"
@canplay="speechState.isApiAudioLoading = false"
@error="onAPIError"
/>
</div>
@@ -1052,7 +1236,7 @@ onUnmounted(() => {
/>
<NButton
type="info"
:loading="isApiAudioLoading"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Message)"
>
测试
@@ -1066,7 +1250,7 @@ onUnmounted(() => {
/>
<NButton
type="info"
:loading="isApiAudioLoading"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Gift)"
>
测试
@@ -1080,7 +1264,7 @@ onUnmounted(() => {
/>
<NButton
type="info"
:loading="isApiAudioLoading"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.SC)"
>
测试
@@ -1094,7 +1278,7 @@ onUnmounted(() => {
/>
<NButton
type="info"
:loading="isApiAudioLoading"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Guard)"
>
测试
@@ -1108,7 +1292,7 @@ onUnmounted(() => {
/>
<NButton
type="info"
:loading="isApiAudioLoading"
:loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Enter)"
>
测试

View File

@@ -93,7 +93,11 @@ export default defineConfig({
],
server: { port: 51000 },
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
define: { 'process.env': {}, global: 'window' },
define: {
'process.env': {},
global: 'window',
__BUILD_TIME__: JSON.stringify(new Date().toISOString())
},
optimizeDeps: {
include: ['@vicons/fluent', '@vicons/ionicons5', 'vue', 'vue-router']
},