diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 19f3557..253a6b9 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -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') } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 8b13789..d05d9ee 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1 +1 @@ - +declare const __BUILD_TIME__: string; diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue index 9440b48..3c07950 100644 --- a/src/views/AboutView.vue +++ b/src/views/AboutView.vue @@ -1,7 +1,25 @@ @@ -19,6 +37,9 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io 关于 + + 构建时间: {{ buildTime.date }} ({{ buildTime.relative }}) + 一个兴趣使然的网站 diff --git a/src/views/open_live/ReadDanmaku.vue b/src/views/open_live/ReadDanmaku.vue index 1b2caf9..84dbbe0 100644 --- a/src/views/open_live/ReadDanmaku.vue +++ b/src/views/open_live/ReadDanmaku.vue @@ -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({ + isSpeaking: false, + speakingText: '', + isApiAudioLoading: false, + apiAudioSrc: '', + canSpeech: false, +}) + const accountInfo = useAccount() const message = useMessage() const route = useRoute() @@ -91,7 +107,6 @@ const settings = useStorage('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() // 用于快速查找礼物合并项 +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() -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 (err.toString().startsWith('AbortError')) { - return + if (apiAudio.value) { + // 设置预加载 + apiAudio.value.preload = 'auto' + + // 播放音频 + const playPromise = apiAudio.value.play() + if (playPromise) { + playPromise.catch((err) => { + if (err.toString().startsWith('AbortError')) { + return + } + console.error('[speakFromAPI] 音频播放失败:', err) + message.error('无法播放语音: ' + err.message) + cancelSpeech() + }) } - console.log(err) - console.log(err) - message.error('无法播放语音:' + err) - 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) { - exist.updateAt = Date.now() - exist.data.num += data.num - exist.data.price += data.price - exist.combineCount ??= 0 - exist.combineCount += data.num - console.log( - `[TTS] ${data.uname} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`, - ) - return + + // 礼物合并逻辑 - 使用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 = (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,139 +569,181 @@ 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() { - const result = await UploadConfig('Speech', settings.value) - if (result) { - message.success('已保存至服务器') - } else { - message.error('保存失败') + 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() { - const result = await DownloadConfig('Speech') - if (result.status === 'success' && result.data) { - settings.value = result.data - message.success('已获取配置文件') - } else if (result.status === 'notfound') { - message.error('未上传配置文件') - } else { - message.error('获取失败: ' + result.msg) + try { + const result = await DownloadConfig('Speech') + if (result.status === 'success' && result.data) { + settings.value = result.data + message.success('已获取配置文件') + } else if (result.status === 'notfound') { + message.error('未上传配置文件') + } else { + message.error('获取失败: ' + result.msg) + } + } catch (error) { + console.error('[downloadConfig] 下载配置失败:', error) + message.error('获取失败: ' + (error instanceof Error ? error.message : '未知错误')) } } + +/** + * 创建测试事件数据 + * @param type 事件类型 + * @param overrides 覆盖默认值的对象 + */ +function createTestEventData(type: EventDataTypes, overrides: Partial): EventModel { + const baseData = { + type, + 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', + } + return { ...baseData, ...overrides } +} + +/** + * 测试不同类型的事件 + * @param type 事件类型 + */ function test(type: EventDataTypes) { + let testData: EventModel 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', - }) + testData = createTestEventData(EventDataTypes.Message, { msg: '测试弹幕' }) break case EventDataTypes.Enter: - forceSpeak({ - type: EventDataTypes.Enter, - 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', - }) + 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(() => { - clearInterval(speechQueueTimer) + // 清理队列定时器 + 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() }) @@ -662,13 +843,13 @@ onUnmounted(() => { - {{ canSpeech ? '停止监听' : '开始监听' }} + {{ speechState.canSpeech ? '停止监听' : '开始监听' }} { 这将覆盖当前设置, 确定? - + 状态 - + { - {{ isSpeaking ? '取消朗读' : '未朗读' }} + {{ speechState.isSpeaking ? `正在朗读: ${speechState.speakingText}` : '未朗读' }} 队列: {{ speakQueue.length }} @@ -854,7 +1035,10 @@ onUnmounted(() => { 音量 @@ -976,7 +1160,7 @@ onUnmounted(() => { /> 测试 @@ -1013,10 +1197,10 @@ onUnmounted(() => { @@ -1052,7 +1236,7 @@ onUnmounted(() => { /> 测试 @@ -1066,7 +1250,7 @@ onUnmounted(() => { /> 测试 @@ -1080,7 +1264,7 @@ onUnmounted(() => { /> 测试 @@ -1094,7 +1278,7 @@ onUnmounted(() => { /> 测试 @@ -1108,7 +1292,7 @@ onUnmounted(() => { /> 测试 diff --git a/vite.config.mts b/vite.config.mts index 3aa9c1a..123a165 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -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'] },