Compare commits

..

4 Commits

19 changed files with 4754 additions and 1319 deletions

View File

@@ -61,14 +61,33 @@ export default antfu(
// TypeScript 相关规则
'ts/no-explicit-any': 'off',
'ts/ban-ts-comment': 'off',
'ts/no-floating-promises': 'off', // 允许不 await Promise
'ts/no-misused-promises': 'off', // 允许在条件表达式中使用 Promise
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': 'off',
// 通用规则
'no-console': 'off',
'unused-imports/no-unused-vars': 'warn',
'eqeqeq': 'off', // 允许使用 == 和 !=
'no-eq-null': 'off', // 允许使用 == null
'@typescript-eslint/strict-boolean-expressions': 'off', // 允许宽松的布尔表达式
// 关闭一些过于严格的规则
'antfu/if-newline': 'off',
'style/brace-style': ['error', '1tbs'],
'prefer-promise-reject-errors': 'off', // 允许 reject 任何值
'no-throw-literal': 'off', // 允许 throw 任何值
'ts/no-unsafe-assignment': 'off', // 允许不安全的赋值
'ts/no-unsafe-member-access': 'off', // 允许不安全的成员访问
'ts/no-unsafe-call': 'off', // 允许不安全的调用
'ts/switch-exhaustiveness-check': 'warn', // 允许 switch 不覆盖所有情况
'ts/restrict-template-expressions': 'off', // 允许模板字符串表达式不受限制
'perfectionist/sort-imports': 'off',
// JSON 相关规则
'jsonc/sort-keys': 'off', // 关闭 JSON key 排序要求
},
},
// 集成 VueVine 配置

View File

@@ -14,12 +14,14 @@ import {
BarChartOutline,
CheckmarkCircleOutline,
CloseCircleOutline,
CloudDownloadOutline,
HardwareChipOutline,
HelpCircle,
LogInOutline,
LogOutOutline,
PeopleOutline,
PersonCircleOutline,
RefreshOutline,
TimeOutline,
TimerOutline,
TrendingUpOutline,
@@ -63,6 +65,7 @@ import {
NInput,
NInputGroup,
NInputGroupLabel,
NPopconfirm,
NQrCode,
NRadioButton,
NRadioGroup,
@@ -155,6 +158,35 @@ const cookieCloud = useTauriStore().getTarget<CookieCloudConfig>(COOKIE_CLOUD_KE
const cookieCloudData = ref((await cookieCloud.get())!)
const isLoadingCookiecloud = ref(false)
// Cookie Cloud 手动操作相关状态
const isSyncingFromCloud = ref(false)
const isCheckingCookie = ref(false)
const lastSyncTime = ref<number>(0)
const lastCheckTime = ref<number>(0)
const COOLDOWN_DURATION = 5 * 1000 // 30秒冷却时间
// 计算剩余冷却时间
const syncCooldownRemaining = ref(0)
const checkCooldownRemaining = ref(0)
let syncCooldownTimer: number | undefined
let checkCooldownTimer: number | undefined
// 更新冷却时间显示
function updateCooldowns() {
const now = Date.now()
syncCooldownRemaining.value = Math.max(0, Math.ceil((lastSyncTime.value + COOLDOWN_DURATION - now) / 1000))
checkCooldownRemaining.value = Math.max(0, Math.ceil((lastCheckTime.value + COOLDOWN_DURATION - now) / 1000))
}
// 启动冷却计时器
function startCooldownTimers() {
if (syncCooldownTimer) clearInterval(syncCooldownTimer)
if (checkCooldownTimer) clearInterval(checkCooldownTimer)
syncCooldownTimer = window.setInterval(updateCooldowns, 1000)
checkCooldownTimer = window.setInterval(updateCooldowns, 1000)
}
async function setCookieCloud() {
try {
isLoadingCookiecloud.value = true
@@ -167,6 +199,60 @@ async function setCookieCloud() {
}
}
// 手动从 Cookie Cloud 同步
async function manualSyncFromCloud() {
const now = Date.now()
if (now - lastSyncTime.value < COOLDOWN_DURATION) {
message.warning(`请等待 ${syncCooldownRemaining.value} 秒后再试`)
return
}
try {
isSyncingFromCloud.value = true
await biliCookie.check(true) // 强制从 CookieCloud 同步
lastSyncTime.value = Date.now()
updateCooldowns()
if (biliCookie.isCookieValid) {
message.success('Cookie 同步成功')
} else {
message.error('Cookie 同步失败或无效')
}
} catch (err: any) {
logError(`手动同步 Cookie 失败: ${err}`)
message.error(`同步失败: ${err.message || '未知错误'}`)
} finally {
isSyncingFromCloud.value = false
}
}
// 手动检查 Cookie 有效性
async function manualCheckCookie() {
const now = Date.now()
if (now - lastCheckTime.value < COOLDOWN_DURATION) {
message.warning(`请等待 ${checkCooldownRemaining.value} 秒后再试`)
return
}
try {
isCheckingCookie.value = true
await biliCookie.check(false) // 只检查本地 Cookie
lastCheckTime.value = Date.now()
updateCooldowns()
if (biliCookie.isCookieValid) {
message.success('Cookie 有效')
} else {
message.error('Cookie 已失效')
}
} catch (err: any) {
logError(`手动检查 Cookie 失败: ${err}`)
message.error(`检查失败: ${err.message || '未知错误'}`)
} finally {
isCheckingCookie.value = false
}
}
// --- Timers ---
let uptimeTimer: number | undefined
let epsTimer: number | undefined
@@ -206,8 +292,8 @@ const danmakuClientStateText = computed(() => {
const danmakuClientStateType = computed(() => {
switch (webfetcher.danmakuClientState) { // Replace with actual exposed state
case 'connected': return 'success'
case 'connecting': 'info'
case 'stopped': 'error'
case 'connecting': return 'info'
case 'stopped': return 'error'
default: return 'default'
}
})
@@ -594,6 +680,9 @@ onMounted(async () => {
// Initialize statistics logic (ensure it runs)
// initInfo(); // Assuming this is called elsewhere or on app startup
// 启动冷却计时器
startCooldownTimers()
})
onUnmounted(() => {
@@ -603,6 +692,9 @@ onUnmounted(() => {
clearInterval(timer.value)
clearTimeout(expiredTimer.value)
clearInterval(countdownTimer.value)
// Clean up cooldown timers
clearInterval(syncCooldownTimer)
clearInterval(checkCooldownTimer)
})
</script>
@@ -940,8 +1032,14 @@ onUnmounted(() => {
style="width: 100%; max-width: 800px; margin-bottom: 1rem;"
>
<template #header-extra>
<NTag>
{{ }}
<NTag
:type="biliCookie.cookieCloudState === 'valid' ? 'success' : biliCookie.cookieCloudState === 'syncing' ? 'info' : biliCookie.cookieCloudState === 'invalid' ? 'error' : 'default'"
>
{{
biliCookie.cookieCloudState === 'valid' ? '已配置' :
biliCookie.cookieCloudState === 'syncing' ? '同步中' :
biliCookie.cookieCloudState === 'invalid' ? '配置无效' : '未配置'
}}
</NTag>
</template>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@@ -980,6 +1078,7 @@ onUnmounted(() => {
placeholder="请输入 Host (可选)"
/>
</NInputGroup>
<NFlex gap="small">
<NButton
v-if="biliCookie.cookieCloudState === 'invalid' || biliCookie.cookieCloudState === 'unset'"
type="primary"
@@ -993,7 +1092,7 @@ onUnmounted(() => {
type="error"
@positive-click="async () => {
await biliCookie.clearCookieCloudConfig();
cookieCloudData = { key: '', password: '' };
cookieCloudData = { key: '', password: '', host: 'https://cookie.vtsuru.live' };
message.success('配置已清除');
}"
>
@@ -1004,6 +1103,42 @@ onUnmounted(() => {
</template>
确定要清除配置吗?
</NPopconfirm>
</NFlex>
<NDivider style="margin: 8px 0;">
手动操作
</NDivider>
<NFlex gap="small">
<NTooltip>
<template #trigger>
<NButton
:disabled="biliCookie.cookieCloudState !== 'valid' || syncCooldownRemaining > 0"
:loading="isSyncingFromCloud"
@click="manualSyncFromCloud"
>
<template #icon>
<NIcon :component="CloudDownloadOutline" />
</template>
{{ syncCooldownRemaining > 0 ? `同步 Cookie (${syncCooldownRemaining}s)` : '从云端同步 Cookie' }}
</NButton>
</template>
{{ biliCookie.cookieCloudState !== 'valid' ? '请先配置有效的 Cookie Cloud' : syncCooldownRemaining > 0 ? `请等待 ${syncCooldownRemaining} 秒` : '手动从 Cookie Cloud 拉取最新的 Cookie' }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton
:disabled="!biliCookie.hasBiliCookie || checkCooldownRemaining > 0"
:loading="isCheckingCookie"
@click="manualCheckCookie"
>
<template #icon>
<NIcon :component="RefreshOutline" />
</template>
{{ checkCooldownRemaining > 0 ? `检查状态 (${checkCooldownRemaining}s)` : '检查 Cookie 状态' }}
</NButton>
</template>
{{ !biliCookie.hasBiliCookie ? '当前没有 Cookie' : checkCooldownRemaining > 0 ? `请等待 ${checkCooldownRemaining} 秒` : '手动检查当前 Cookie 的有效性' }}
</NTooltip>
</NFlex>
</div>
</NCard>
</NTabPane>

View File

@@ -4,7 +4,7 @@ import type { MenuOption } from 'naive-ui'
// 引入 Tauri 插件
import { openUrl } from '@tauri-apps/plugin-opener'
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Settings24Filled } from '@vicons/fluent'
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5'
import { NA, NButton, NCard, NInput, NLayout, NLayoutContent, NLayoutSider, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui'
@@ -116,6 +116,12 @@ const menuOptions = computed(() => {
key: 'danmaku-auto-action-manage',
icon: () => h(FlashAuto24Filled),
},
{
label: () =>
h(RouterLink, { to: { name: 'client-read-danmaku' } }, () => '读弹幕'),
key: 'read-danmaku',
icon: () => h(Mic24Filled),
},
{
label: () =>
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),

File diff suppressed because it is too large Load Diff

View File

@@ -173,7 +173,7 @@ const highlightPatterns = computed(() => {
return allPatterns
})
const MAX_LENGTH = 20
const MAX_LENGTH = 40
const WARNING_THRESHOLD = 16
function evaluateTemplateForUI(template: string): string {

View File

@@ -14,6 +14,7 @@ import {
import { openUrl } from '@tauri-apps/plugin-opener'
import { relaunch } from '@tauri-apps/plugin-process'
import { check } from '@tauri-apps/plugin-updater'
import { h } from 'vue'
import { isLoggedIn, useAccount } from '@/api/account'
import { CN_HOST, isDev } from '@/data/constants'
import { useWebFetcher } from '@/store/useWebFetcher'
@@ -30,10 +31,14 @@ const accountInfo = useAccount()
export const clientInited = ref(false)
let tray: TrayIcon
let heartbeatTimer: number | null = null
let updateCheckTimer: number | null = null
let updateNotificationRef: any = null
async function sendHeartbeat() {
try {
await invoke('heartbeat')
await invoke('heartbeat', undefined, {
headers: [['Origin', location.host]]
})
} catch (error) {
console.error('发送心跳失败:', error)
}
@@ -58,12 +63,164 @@ export function stopHeartbeat() {
}
}
export function startUpdateCheck() {
// 立即检查一次更新
void checkUpdatePeriodically()
// 之后每 6 小时检查一次更新
updateCheckTimer = window.setInterval(() => {
void checkUpdatePeriodically()
}, 6 * 60 * 60 * 1000) // 6 hours
info('[更新检查] 定时器已启动,间隔 6 小时')
}
export function stopUpdateCheck() {
if (updateCheckTimer !== null) {
clearInterval(updateCheckTimer)
updateCheckTimer = null
info('[更新检查] 定时器已停止')
}
if (updateNotificationRef) {
updateNotificationRef.destroy()
updateNotificationRef = null
}
}
async function checkUpdatePeriodically() {
try {
info('[更新检查] 开始检查更新...')
const update = await check()
if (update) {
info(`[更新检查] 发现新版本: ${update.version}`)
// 发送 Windows 通知
const permissionGranted = await isPermissionGranted()
if (permissionGranted) {
sendNotification({
title: 'VTsuru.Client 更新可用',
body: `发现新版本 ${update.version},点击通知查看详情`,
})
}
// 显示不可关闭的 NaiveUI notification
if (!updateNotificationRef) {
updateNotificationRef = window.$notification.warning({
title: '发现新版本',
content: `VTsuru.Client ${update.version} 现已可用`,
meta: update.date,
action: () => {
return h('div', { style: 'display: flex; gap: 8px; margin-top: 8px;' }, [
h(
'button',
{
class: 'n-button n-button--primary-type n-button--small-type',
onClick: () => {
void handleUpdateInstall(update)
},
},
'立即更新',
),
h(
'button',
{
class: 'n-button n-button--default-type n-button--small-type',
onClick: () => handleUpdateDismiss(),
},
'稍后提醒',
),
])
},
closable: false,
duration: 0, // 不自动关闭
})
}
} else {
info('[更新检查] 当前已是最新版本')
}
} catch (error) {
warn(`[更新检查] 检查更新失败: ${error}`)
}
}
async function handleUpdateInstall(update: any) {
try {
// 关闭提示
if (updateNotificationRef) {
updateNotificationRef.destroy()
updateNotificationRef = null
}
// 显示下载进度通知
let downloaded = 0
let contentLength = 0
const progressNotification = window.$notification.info({
title: '正在下载更新',
content: '更新下载中,请稍候...',
closable: false,
duration: 0,
})
info('[更新] 开始下载并安装更新')
await update.downloadAndInstall((event: any) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength || 0
info(`[更新] 开始下载 ${contentLength} 字节`)
break
case 'Progress': {
downloaded += event.data.chunkLength
const progress = contentLength > 0 ? Math.round((downloaded / contentLength) * 100) : 0
progressNotification.content = `下载进度: ${progress}% (${Math.round(downloaded / 1024 / 1024)}MB / ${Math.round(contentLength / 1024 / 1024)}MB)`
info(`[更新] 已下载 ${downloaded} / ${contentLength} 字节`)
break
}
case 'Finished':
info('[更新] 下载完成')
progressNotification.content = '下载完成,正在安装...'
break
}
})
progressNotification.destroy()
info('[更新] 更新安装完成,准备重启应用')
window.$notification.success({
title: '更新完成',
content: '应用将在 3 秒后重启',
duration: 3000,
})
// 延迟 3 秒后重启
await new Promise(resolve => setTimeout(resolve, 3000))
await relaunch()
} catch (error) {
warn(`[更新] 安装更新失败: ${error}`)
window.$notification.error({
title: '更新失败',
content: `更新安装失败: ${error}`,
duration: 5000,
})
}
}
function handleUpdateDismiss() {
if (updateNotificationRef) {
updateNotificationRef.destroy()
updateNotificationRef = null
}
info('[更新] 用户选择稍后更新')
}
export async function initAll(isOnBoot: boolean) {
const setting = useSettings()
if (clientInited.value) {
return
}
checkUpdate()
// 初始检查更新(不阻塞初始化)
if (!isDev) {
void checkUpdate()
}
const appWindow = getCurrentWindow()
let permissionGranted = await isPermissionGranted()
@@ -86,7 +243,7 @@ export async function initAll(isOnBoot: boolean) {
}
}
initNotificationHandler()
const detach = await attachConsole()
await attachConsole()
const settings = useSettings()
const biliCookie = useBiliCookie()
await settings.init()
@@ -135,13 +292,9 @@ export async function initAll(isOnBoot: boolean) {
tooltip: 'VTsuru 事件收集器',
icon: iconData,
action: (event) => {
switch (event.type) {
case 'DoubleClick':
if (event.type === 'DoubleClick' || event.type === 'Click') {
appWindow.show()
break
case 'Click':
appWindow.show()
break
appWindow.setFocus()
}
},
}
@@ -179,7 +332,13 @@ export async function initAll(isOnBoot: boolean) {
useAutoAction().init()
useBiliFunction().init()
//startHeartbeat()
// startHeartbeat()
// 启动定期更新检查
if (!isDev) {
startUpdateCheck()
}
clientInited.value = true
}
export function OnClientUnmounted() {
@@ -188,39 +347,14 @@ export function OnClientUnmounted() {
}
stopHeartbeat()
stopUpdateCheck()
tray.close()
// useDanmakuWindow().closeWindow();
}
async function checkUpdate() {
const update = await check()
console.log(update)
if (update) {
console.log(
`found update ${update.version} from ${update.date} with notes ${update.body}`,
)
let downloaded = 0
let contentLength = 0
// alternatively we could also call update.download() and update.install() separately
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength || 0
console.log(`started downloading ${event.data.contentLength} bytes`)
break
case 'Progress':
downloaded += event.data.chunkLength
console.log(`downloaded ${downloaded} from ${contentLength}`)
break
case 'Finished':
console.log('download finished')
break
}
})
console.log('update installed')
await relaunch()
}
export async function checkUpdate() {
// 手动检查更新(保留用于手动触发)
await checkUpdatePeriodically()
}
export const isInitedDanmakuClient = ref(false)

6
src/components.d.ts vendored
View File

@@ -19,6 +19,9 @@ declare module 'vue' {
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NDataTable: typeof import('naive-ui')['NDataTable']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
@@ -26,7 +29,10 @@ declare module 'vue' {
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime']

View File

@@ -48,7 +48,7 @@ export function InitVTsuru() {
}
async function InitOther() {
if (process.env.NODE_ENV !== 'development' && !window.$route.path.startsWith('/obs')) {
if (import.meta.env.MODE !== 'development' && !window.$route.path.startsWith('/obs')) {
const mod = await import('@hyperdx/browser')
const HyperDX = (mod as any).default ?? mod
HyperDX.init({
@@ -59,7 +59,7 @@ async function InitOther() {
advancedNetworkCapture: true, // Capture full HTTP request/response headers and bodies (default false)
ignoreUrls: [/localhost/i],
})
// 将实例挂到窗口便于后续设置全局属性可选
// 将实例挂到窗口,便于后续设置全局属性(可选)
;(window as any).__HyperDX__ = HyperDX
}
// 加载其他数据

View File

@@ -18,7 +18,10 @@ void import('./data/Initializer').then(m => m.InitVTsuru())
const isTauri = () => (window as any).__TAURI__ !== undefined || (window as any).__TAURI_INTERNAL__ !== undefined || '__TAURI__' in window
if (isTauri()) {
// 仅在 Tauri 环境下才动态加载相关初始化,避免把 @tauri-apps/* 打入入口
void import('./client/data/initialize').then(m => m.startHeartbeat())
void import('./client/data/initialize').then(m => {
m.startHeartbeat();
m.checkUpdate();
})
}
window.$mitt = emitter

View File

@@ -47,6 +47,15 @@ export default {
forceReload: true,
},
},
{
path: 'read-danmaku',
name: 'client-read-danmaku',
component: async () => import('@/client/ClientReadDanmaku.vue'),
meta: {
title: '读弹幕',
forceReload: true,
},
},
{
path: 'danmaku-window',
name: 'client-danmaku-window-redirect',

View File

@@ -0,0 +1,699 @@
import { useStorage } from '@vueuse/core'
import EasySpeech from 'easy-speech'
import GraphemeSplitter from 'grapheme-splitter'
import { useMessage } from 'naive-ui'
import { reactive, ref } from 'vue'
import { clearInterval, setInterval } from 'worker-timers'
import type { EventModel } from '@/api/api-models'
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
import { EventDataTypes } from '@/api/api-models'
import { FETCH_API } from '@/data/constants'
export interface SpeechSettings {
speechInfo: SpeechInfo
danmakuTemplate: string
scTemplate: string
guardTemplate: string
giftTemplate: string
enterTemplate: string
voiceType: 'local' | 'api'
voiceAPISchemeType: 'http' | 'https'
voiceAPI: string
splitText: boolean
useAPIDirectly: boolean
combineGiftDelay: number | undefined
}
export interface SpeechInfo {
volume: number
pitch: number
rate: number
voice: string
}
export interface SpeechState {
isSpeaking: boolean
speakingText: string
isApiAudioLoading: boolean
apiAudioSrc: string
canSpeech: boolean
isInitialized: boolean
}
export interface QueueItem {
updateAt: number
combineCount?: number
data: EventModel
}
const MAX_QUEUE_SIZE = 50
const DEFAULT_SETTINGS: SpeechSettings = {
speechInfo: {
volume: 1,
pitch: 1,
rate: 1,
voice: '',
},
danmakuTemplate: '{name} 说: {message}',
scTemplate: '{name} 发送了醒目留言: {message}',
guardTemplate: '感谢 {name} 的 {count} 个月 {guard_level}',
giftTemplate: '感谢 {name} 赠送的 {count} 个 {gift_name}',
enterTemplate: '欢迎 {name} 进入直播间',
voiceType: 'local',
voiceAPISchemeType: 'https',
voiceAPI: 'voice.vtsuru.live/voice/bert-vits2?text={{text}}&id=1&format=mp3&streaming=true',
useAPIDirectly: false,
splitText: false,
combineGiftDelay: 2,
}
export const templateConstants = {
name: {
name: '用户名',
words: '{name}',
regex: /\{\s*name\s*\}/gi,
},
message: {
name: '弹幕内容',
words: '{message}',
regex: /\{\s*message\s*\}/gi,
},
guard_level: {
name: '舰长等级',
words: '{guard_level}',
regex: /\{\s*guard_level\s*\}/gi,
},
guard_num: {
name: '上舰数量',
words: '{guard_num}',
regex: /\{\s*guard_num\s*\}/gi,
},
fans_medal_level: {
name: '粉丝勋章等级',
words: '{fans_medal_level}',
regex: /\{\s*fans_medal_level\s*\}/gi,
},
price: {
name: '价格',
words: '{price}',
regex: /\{\s*price\s*\}/gi,
},
count: {
name: '数量',
words: '{count}',
regex: /\{\s*count\s*\}/gi,
},
gift_name: {
name: '礼物名称',
words: '{gift_name}',
regex: /\{\s*gift_name\s*\}/gi,
},
}
// Singleton state
let speechServiceInstance: ReturnType<typeof createSpeechService> | null = null
function createSpeechService() {
const message = useMessage()
const accountInfo = useAccount()
const splitter = new GraphemeSplitter()
const settings = useStorage<SpeechSettings>('Setting.Speech', DEFAULT_SETTINGS)
const speechState = reactive<SpeechState>({
isSpeaking: false,
speakingText: '',
isApiAudioLoading: false,
apiAudioSrc: '',
canSpeech: false,
isInitialized: false,
})
const speakQueue = ref<QueueItem[]>([])
const giftCombineMap = new Map<string, number>()
const readedDanmaku = ref(0)
const apiAudio = ref<HTMLAudioElement>()
let checkTimer: number | undefined
let speechQueueTimer: number | undefined
const speechSynthesisInfo = ref<{
speechSynthesis: SpeechSynthesis | undefined
speechSynthesisUtterance: SpeechSynthesisUtterance | undefined
speechSynthesisVoice: SpeechSynthesisVoice | undefined
onvoiceschanged: boolean
}>()
/**
* 初始化语音服务
*/
async function initialize() {
if (speechState.isInitialized) {
return
}
try {
await EasySpeech.init({ maxTimeout: 5000, interval: 250 })
speechSynthesisInfo.value = EasySpeech.detect() as any
// 自动选择默认语音
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)
setTimeout(() => clearInterval(voiceCheckTimer), 10000)
}
// 启动队列处理
speechQueueTimer = setInterval(() => {
processQueue()
}, 250)
speechState.isInitialized = true
console.log('[TTS] 语音服务初始化完成')
} catch (error) {
console.error('[TTS] 初始化失败:', error)
message.error('语音服务初始化失败')
}
}
/**
* 销毁语音服务
*/
function destroy() {
if (speechQueueTimer) {
clearInterval(speechQueueTimer)
speechQueueTimer = undefined
}
if (checkTimer) {
clearInterval(checkTimer)
checkTimer = undefined
}
cancelSpeech()
giftCombineMap.clear()
speakQueue.value = []
speechState.isInitialized = false
console.log('[TTS] 语音服务已销毁')
}
/**
* 根据舰长等级数字返回对应的中文名称
*/
function getGuardLevelName(guardLevel: number): string {
switch (guardLevel) {
case 1: return '总督'
case 2: return '提督'
case 3: return '舰长'
default: return ''
}
}
/**
* 全角转半角
*/
function fullWidthToHalfWidth(str: string) {
let result = str.replace(/[\uFF01-\uFF5E]/g, (ch) => {
return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)
})
result = result.replace(/\u3000/g, ' ')
return result
}
/**
* 从事件数据生成要朗读的文本
*/
function getTextFromDanmaku(data: EventModel | undefined): string | undefined {
if (!data) {
return
}
let text: string = ''
switch (data.type) {
case EventDataTypes.Message:
if (!settings.value.danmakuTemplate) return
text = settings.value.danmakuTemplate
break
case EventDataTypes.SC:
if (!settings.value.scTemplate) return
text = settings.value.scTemplate
break
case EventDataTypes.Guard:
if (!settings.value.guardTemplate) return
text = settings.value.guardTemplate
break
case EventDataTypes.Gift:
if (!settings.value.giftTemplate) return
text = settings.value.giftTemplate
break
case EventDataTypes.Enter:
if (!settings.value.enterTemplate) return
text = settings.value.enterTemplate
break
case EventDataTypes.Like:
case EventDataTypes.SCDel:
case EventDataTypes.Follow:
// 这些事件类型不需要语音播报
return
}
text = text
.replace(
templateConstants.name.regex,
settings.value.voiceType == 'api' && settings.value.splitText ? `'${data.uname || ''}'` : (data.uname || ''),
)
.replace(templateConstants.count.regex, (data.num ?? 0).toString())
.replace(templateConstants.price.regex, (data.price ?? 0).toString())
.replace(templateConstants.message.regex, data.msg || '')
.replace(
templateConstants.guard_level.regex,
getGuardLevelName(data.guard_level),
)
.replace(templateConstants.fans_medal_level.regex, (data.fans_medal_level ?? 0).toString())
.trim()
if (data.type === EventDataTypes.Message) {
text = text.replace(/\[.*?\]/g, ' ')
} else if (data.type === EventDataTypes.Gift) {
text = text.replace(templateConstants.gift_name.regex, data.msg || '')
} else if (data.type === EventDataTypes.Guard) {
text = text.replace(templateConstants.guard_num.regex, (data.num ?? 0).toString())
}
text = fullWidthToHalfWidth(text)
.replace(/[^0-9a-z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF,.:'"\s]/gi, '')
.normalize('NFKC')
return text
}
/**
* 插入空格(用于拆分文本)
*/
function insertSpaces(sentence: string) {
sentence = sentence.replace(/\b[A-Z]{2,}\b/g, (match) => {
return match.split('').join(' ')
})
sentence = sentence.replace(/\s+/g, ' ').trim()
return sentence
}
/**
* 使用本地TTS朗读
*/
function speakDirect(text: string) {
try {
const synth = window.speechSynthesis
if (!synth) {
console.error('[TTS] 当前浏览器不支持语音合成')
return
}
synth.cancel()
const u = new SpeechSynthesisUtterance()
u.text = text
const voices = synth.getVoices()
const voice = voices.find(v => v.name === settings.value.speechInfo.voice)
if (voice) {
u.voice = voice
u.volume = settings.value.speechInfo.volume
u.rate = settings.value.speechInfo.rate
u.pitch = settings.value.speechInfo.pitch
synth.speak(u)
u.onend = () => {
cancelSpeech()
}
u.onerror = (err) => {
if (err.error == 'interrupted') {
return
}
console.error('[TTS] 播放错误:', err)
message.error(`无法播放语音: ${err.error}`)
cancelSpeech()
}
}
} catch (err) {
console.error('[TTS] 本地语音合成失败:', err)
cancelSpeech()
}
}
/**
* 构建API请求URL
*/
function buildApiUrl(text: string): string | null {
if (!settings.value.voiceAPI) {
message.error('未设置语音API')
return null
}
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 {
const tempURL = new URL(url)
const isVtsuruAPI = settings.value.voiceAPI.toLowerCase().trim().startsWith('voice.vtsuru.live')
if (isVtsuruAPI) {
tempURL.searchParams.set('vtsuruId', accountInfo.value?.id.toString() ?? '-1')
if (splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 100) {
message.error(`本站提供的测试接口字数不允许超过 100 字`)
return null
}
}
return tempURL.toString()
} catch (err) {
console.error('[TTS] 无效的API地址:', err)
message.error(`无效的API地址: ${url}`)
return null
}
}
/**
* 使用API TTS朗读
*/
function speakFromAPI(text: string) {
const url = buildApiUrl(text)
if (!url) {
cancelSpeech()
return
}
speechState.isSpeaking = true
speechState.isApiAudioLoading = true
speechState.apiAudioSrc = url
}
/**
* 处理队列中的下一个事件
*/
async function processQueue() {
if (speechState.isSpeaking || speakQueue.value.length == 0) {
return
}
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
}
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) {
speechState.isSpeaking = true
readedDanmaku.value++
speechState.speakingText = text
if (checkTimer) {
clearInterval(checkTimer)
}
checkTimer = setInterval(() => {
message.error('语音播放超时')
cancelSpeech()
}, 30000)
if (settings.value.voiceType == 'local') {
speakDirect(text)
} else {
text = settings.value.splitText ? insertSpaces(text) : text
speakFromAPI(text)
}
console.log(`[TTS] 正在朗读: ${text}`)
}
}
/**
* 取消当前语音播放
*/
function cancelSpeech() {
speechState.isSpeaking = false
if (checkTimer) {
clearInterval(checkTimer)
checkTimer = undefined
}
speechState.isApiAudioLoading = false
if (apiAudio.value && !apiAudio.value.paused) {
apiAudio.value.pause()
}
EasySpeech.cancel()
speechState.speakingText = ''
}
/**
* 接收事件并添加到队列
*/
function addToQueue(data: EventModel) {
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 && settings.value.combineGiftDelay) {
const giftKey = `${data.uid}-${data.msg}`
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)
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,
})
// 队列清理
if (speakQueue.value.length > MAX_QUEUE_SIZE) {
const removed = speakQueue.value.splice(0, speakQueue.value.length - MAX_QUEUE_SIZE)
console.warn(`[TTS] 队列过长,已移除 ${removed.length} 个旧项目`)
}
}
/**
* 强制播放指定事件(插队)
*/
function forceSpeak(data: EventModel) {
cancelSpeech()
const index = speakQueue.value.findIndex(v => v.data == data)
if (index !== -1) {
speakQueue.value.splice(index, 1)
}
speakQueue.value.unshift({
updateAt: 0,
data,
})
}
/**
* 从队列中移除指定项
*/
function removeFromQueue(item: QueueItem) {
const index = speakQueue.value.indexOf(item)
if (index !== -1) {
speakQueue.value.splice(index, 1)
}
}
/**
* 清空队列
*/
function clearQueue() {
speakQueue.value = []
giftCombineMap.clear()
}
/**
* 开始监听
*/
function startSpeech() {
speechState.canSpeech = true
message.success('服务已启动')
}
/**
* 停止监听
*/
function stopSpeech() {
speechState.canSpeech = false
// 清空队列
speakQueue.value = []
// 取消当前正在播放的语音
cancelSpeech()
message.success('已停止监听')
}
/**
* 上传配置到服务器
*/
async function uploadConfig() {
try {
const result = await UploadConfig('Speech', settings.value)
if (result) {
message.success('已保存至服务器')
} else {
message.error('保存失败')
}
} catch (error) {
console.error('[TTS] 上传配置失败:', 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
message.success('已获取配置文件')
} else if (result.status === 'notfound') {
message.error('未上传配置文件')
} else {
message.error(`获取失败: ${result.msg}`)
}
} catch (error) {
console.error('[TTS] 下载配置失败:', error)
message.error(`获取失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 获取可用的语音列表
*/
function getAvailableVoices() {
const languageDisplayName = new Intl.DisplayNames(['zh'], { type: 'language' })
return EasySpeech.voices().map((v) => {
return {
label: `[${languageDisplayName.of(v.lang)}] ${v.name}`,
value: v.name,
}
})
}
return {
// State
settings,
speechState,
speakQueue,
readedDanmaku,
speechSynthesisInfo,
apiAudio,
// Methods
initialize,
destroy,
addToQueue,
forceSpeak,
removeFromQueue,
clearQueue,
startSpeech,
stopSpeech,
cancelSpeech,
uploadConfig,
downloadConfig,
getTextFromDanmaku,
getAvailableVoices,
buildApiUrl,
}
}
/**
* 使用语音服务(单例模式)
*/
export function useSpeechService() {
if (!speechServiceInstance) {
speechServiceInstance = createSpeechService()
}
return speechServiceInstance
}

View File

@@ -410,7 +410,7 @@ onMounted(async () => {
background: cardBgMedium,
border: borderSystem.medium,
borderRadius: borderRadius.large,
boxShadow: shadowSystem.light,
boxShadow: 'none',
cursor: item.route ? 'pointer' : 'default',
}" hoverable class="feature-card" @click="handleFunctionClick(item)">
<NFlex vertical>
@@ -463,7 +463,7 @@ onMounted(async () => {
background: cardBgMedium,
border: borderSystem.light,
borderRadius: borderRadius.large,
boxShadow: shadowSystem.light,
boxShadow: 'none',
}" hoverable class="feature-card">
<NFlex vertical>
<NFlex align="center" style="margin-bottom: 10px;">
@@ -486,7 +486,7 @@ onMounted(async () => {
background: cardBgMedium,
border: borderSystem.light,
borderRadius: borderRadius.large,
boxShadow: shadowSystem.light,
boxShadow: 'none',
}" hoverable class="feature-card">
<NFlex vertical>
<NFlex align="center" style="margin-bottom: 10px;">
@@ -534,7 +534,7 @@ onMounted(async () => {
width: '90vw',
maxWidth: '1400px',
borderRadius: borderRadius.xlarge,
boxShadow: shadowSystem.light,
boxShadow: 'none',
}">
<NFlex vertical>
<NFlex justify="center" align="center" style="margin-bottom: 30px;">
@@ -677,7 +677,7 @@ onMounted(async () => {
&:hover
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
&::before
left: 100%;
@@ -700,14 +700,14 @@ onMounted(async () => {
&:hover
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.entry-card
transition: all 0.2s ease;
&:hover
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.streamer-avatar
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@@ -738,7 +738,7 @@ onMounted(async () => {
transition: all 0.2s ease;
&:hover
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
:deep(.n-button--small)
border-radius: 8px;
@@ -843,7 +843,7 @@ onMounted(async () => {
&:hover
transform: translateY(-3px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.08);
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.15);

View File

@@ -64,7 +64,7 @@ interface HistoryUpstatRecordModel {
}
interface GuardMemberModel {
guardUid: number
guardOUId: string
username: string
guardLevel: string
accompanyDays: number
@@ -127,9 +127,12 @@ const guardPagination = computed(() => ({
// 舰长列表表格列定义
const guardColumns: DataTableColumns<GuardMemberModel> = [
{
title: 'UID',
key: 'guardUid',
width: 100,
title: 'OUID',
key: 'guardOUId',
width: 250,
ellipsis: {
tooltip: true,
},
},
{
title: '用户名',

View File

@@ -316,10 +316,15 @@ function OnFileListChange(files: UploadFileInfo[]) {
}
function onUpdateClick(item: ResponsePointGoodModel) {
const copiedItem = JSON.parse(JSON.stringify(item))
// 确保 setting 对象存在
if (!copiedItem.setting) {
copiedItem.setting = {
allowGuardLevel: 0,
}
}
currentGoodsModel.value = {
goods: JSON.parse(JSON.stringify({
...item,
})),
goods: copiedItem,
fileList: item.cover
? [
{
@@ -949,6 +954,9 @@ onMounted(() => { })
:checked="currentGoodsModel.goods.setting?.guardFree != undefined"
@update:checked="
(v) => {
if (!currentGoodsModel.goods.setting) {
currentGoodsModel.goods.setting = { allowGuardLevel: 0 };
}
// @ts-ignore
currentGoodsModel.goods.setting.guardFree = v ? { year: undefined, month: undefined } : undefined;
}
@@ -978,12 +986,22 @@ onMounted(() => { })
:gap="8"
>
<NSelect
v-model:value="currentGoodsModel.goods.setting.guardFree.year"
:value="currentGoodsModel.goods.setting?.guardFree?.year"
@update:value="(v) => {
if (currentGoodsModel.goods.setting?.guardFree) {
currentGoodsModel.goods.setting.guardFree.year = v;
}
}"
:options="allowedYearOptions"
placeholder="请选择年份"
/>
<NSelect
v-model:value="currentGoodsModel.goods.setting.guardFree.month"
:value="currentGoodsModel.goods.setting?.guardFree?.month"
@update:value="(v) => {
if (currentGoodsModel.goods.setting?.guardFree) {
currentGoodsModel.goods.setting.guardFree.month = v;
}
}"
:options="allowedMonthOptions"
placeholder="请选择月份"
/>
@@ -1009,7 +1027,15 @@ onMounted(() => { })
</NTooltip>
</NText>
<NRadioGroup v-model:value="currentGoodsModel.goods.setting.allowGuardLevel">
<NRadioGroup
:value="currentGoodsModel.goods.setting?.allowGuardLevel ?? 0"
@update:value="(v) => {
if (!currentGoodsModel.goods.setting) {
currentGoodsModel.goods.setting = { allowGuardLevel: 0 };
}
currentGoodsModel.goods.setting.allowGuardLevel = v;
}"
>
<NRadioButton :value="0">
不限
</NRadioButton>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -553,6 +553,7 @@ onMounted(async () => {
</div>
</div>
<NDivider v-if="goods.length > 0" />
<!-- 礼物列表区域 -->
<NSpin
:show="isLoading"
@@ -651,6 +652,7 @@ onMounted(async () => {
</NGi>
</NGrid>
</NSpin>
<NDivider v-if="goods.length > 0" />
<!-- 兑换确认模态框 -->
<NModal

View File

@@ -12,13 +12,20 @@
"resolveJsonModule": true,
"types": ["node", "vue-vine/macros", "jszip"],
"allowJs": false,
"strict": true,
"strict": false,
"alwaysStrict": false,
"noImplicitAny": false,
"noImplicitThis": false,
"strictBindCallApply": false,
"strictFunctionTypes": false,
"strictNullChecks": false,
"strictPropertyInitialization": false,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"isolatedModules": true
"isolatedModules": true,
"skipLibCheck": true,
"sourceMap": true
},
"references": [{ "path": "./tsconfig.node.json" }],
"include": [