mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
Compare commits
4 Commits
96f6169a6c
...
a9453aa919
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9453aa919 | ||
|
|
5959baf2e8 | ||
|
|
a38fe4a050 | ||
|
|
89c4c05faf |
@@ -61,14 +61,33 @@ export default antfu(
|
|||||||
// TypeScript 相关规则
|
// TypeScript 相关规则
|
||||||
'ts/no-explicit-any': 'off',
|
'ts/no-explicit-any': 'off',
|
||||||
'ts/ban-ts-comment': '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',
|
'no-console': 'off',
|
||||||
'unused-imports/no-unused-vars': 'warn',
|
'unused-imports/no-unused-vars': 'warn',
|
||||||
|
'eqeqeq': 'off', // 允许使用 == 和 !=
|
||||||
|
'no-eq-null': 'off', // 允许使用 == null
|
||||||
|
'@typescript-eslint/strict-boolean-expressions': 'off', // 允许宽松的布尔表达式
|
||||||
|
|
||||||
// 关闭一些过于严格的规则
|
// 关闭一些过于严格的规则
|
||||||
'antfu/if-newline': 'off',
|
'antfu/if-newline': 'off',
|
||||||
'style/brace-style': ['error', '1tbs'],
|
'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 配置
|
// 集成 VueVine 配置
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
BarChartOutline,
|
BarChartOutline,
|
||||||
CheckmarkCircleOutline,
|
CheckmarkCircleOutline,
|
||||||
CloseCircleOutline,
|
CloseCircleOutline,
|
||||||
|
CloudDownloadOutline,
|
||||||
HardwareChipOutline,
|
HardwareChipOutline,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
LogInOutline,
|
LogInOutline,
|
||||||
LogOutOutline,
|
LogOutOutline,
|
||||||
PeopleOutline,
|
PeopleOutline,
|
||||||
PersonCircleOutline,
|
PersonCircleOutline,
|
||||||
|
RefreshOutline,
|
||||||
TimeOutline,
|
TimeOutline,
|
||||||
TimerOutline,
|
TimerOutline,
|
||||||
TrendingUpOutline,
|
TrendingUpOutline,
|
||||||
@@ -63,6 +65,7 @@ import {
|
|||||||
NInput,
|
NInput,
|
||||||
NInputGroup,
|
NInputGroup,
|
||||||
NInputGroupLabel,
|
NInputGroupLabel,
|
||||||
|
NPopconfirm,
|
||||||
NQrCode,
|
NQrCode,
|
||||||
NRadioButton,
|
NRadioButton,
|
||||||
NRadioGroup,
|
NRadioGroup,
|
||||||
@@ -155,6 +158,35 @@ const cookieCloud = useTauriStore().getTarget<CookieCloudConfig>(COOKIE_CLOUD_KE
|
|||||||
const cookieCloudData = ref((await cookieCloud.get())!)
|
const cookieCloudData = ref((await cookieCloud.get())!)
|
||||||
const isLoadingCookiecloud = ref(false)
|
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() {
|
async function setCookieCloud() {
|
||||||
try {
|
try {
|
||||||
isLoadingCookiecloud.value = true
|
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 ---
|
// --- Timers ---
|
||||||
let uptimeTimer: number | undefined
|
let uptimeTimer: number | undefined
|
||||||
let epsTimer: number | undefined
|
let epsTimer: number | undefined
|
||||||
@@ -206,8 +292,8 @@ const danmakuClientStateText = computed(() => {
|
|||||||
const danmakuClientStateType = computed(() => {
|
const danmakuClientStateType = computed(() => {
|
||||||
switch (webfetcher.danmakuClientState) { // Replace with actual exposed state
|
switch (webfetcher.danmakuClientState) { // Replace with actual exposed state
|
||||||
case 'connected': return 'success'
|
case 'connected': return 'success'
|
||||||
case 'connecting': 'info'
|
case 'connecting': return 'info'
|
||||||
case 'stopped': 'error'
|
case 'stopped': return 'error'
|
||||||
default: return 'default'
|
default: return 'default'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -594,6 +680,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Initialize statistics logic (ensure it runs)
|
// Initialize statistics logic (ensure it runs)
|
||||||
// initInfo(); // Assuming this is called elsewhere or on app startup
|
// initInfo(); // Assuming this is called elsewhere or on app startup
|
||||||
|
|
||||||
|
// 启动冷却计时器
|
||||||
|
startCooldownTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -603,6 +692,9 @@ onUnmounted(() => {
|
|||||||
clearInterval(timer.value)
|
clearInterval(timer.value)
|
||||||
clearTimeout(expiredTimer.value)
|
clearTimeout(expiredTimer.value)
|
||||||
clearInterval(countdownTimer.value)
|
clearInterval(countdownTimer.value)
|
||||||
|
// Clean up cooldown timers
|
||||||
|
clearInterval(syncCooldownTimer)
|
||||||
|
clearInterval(checkCooldownTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -940,8 +1032,14 @@ onUnmounted(() => {
|
|||||||
style="width: 100%; max-width: 800px; margin-bottom: 1rem;"
|
style="width: 100%; max-width: 800px; margin-bottom: 1rem;"
|
||||||
>
|
>
|
||||||
<template #header-extra>
|
<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>
|
</NTag>
|
||||||
</template>
|
</template>
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
@@ -980,6 +1078,7 @@ onUnmounted(() => {
|
|||||||
placeholder="请输入 Host (可选)"
|
placeholder="请输入 Host (可选)"
|
||||||
/>
|
/>
|
||||||
</NInputGroup>
|
</NInputGroup>
|
||||||
|
<NFlex gap="small">
|
||||||
<NButton
|
<NButton
|
||||||
v-if="biliCookie.cookieCloudState === 'invalid' || biliCookie.cookieCloudState === 'unset'"
|
v-if="biliCookie.cookieCloudState === 'invalid' || biliCookie.cookieCloudState === 'unset'"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -993,7 +1092,7 @@ onUnmounted(() => {
|
|||||||
type="error"
|
type="error"
|
||||||
@positive-click="async () => {
|
@positive-click="async () => {
|
||||||
await biliCookie.clearCookieCloudConfig();
|
await biliCookie.clearCookieCloudConfig();
|
||||||
cookieCloudData = { key: '', password: '' };
|
cookieCloudData = { key: '', password: '', host: 'https://cookie.vtsuru.live' };
|
||||||
message.success('配置已清除');
|
message.success('配置已清除');
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@@ -1004,6 +1103,42 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
确定要清除配置吗?
|
确定要清除配置吗?
|
||||||
</NPopconfirm>
|
</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>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { MenuOption } from 'naive-ui'
|
|||||||
// 引入 Tauri 插件
|
// 引入 Tauri 插件
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
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 { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5'
|
||||||
import { NA, NButton, NCard, NInput, NLayout, NLayoutContent, NLayoutSider, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui'
|
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',
|
key: 'danmaku-auto-action-manage',
|
||||||
icon: () => h(FlashAuto24Filled),
|
icon: () => h(FlashAuto24Filled),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () =>
|
||||||
|
h(RouterLink, { to: { name: 'client-read-danmaku' } }, () => '读弹幕'),
|
||||||
|
key: 'read-danmaku',
|
||||||
|
icon: () => h(Mic24Filled),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: () =>
|
label: () =>
|
||||||
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),
|
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),
|
||||||
|
|||||||
1246
src/client/ClientReadDanmaku.vue
Normal file
1246
src/client/ClientReadDanmaku.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -173,7 +173,7 @@ const highlightPatterns = computed(() => {
|
|||||||
return allPatterns
|
return allPatterns
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_LENGTH = 20
|
const MAX_LENGTH = 40
|
||||||
const WARNING_THRESHOLD = 16
|
const WARNING_THRESHOLD = 16
|
||||||
|
|
||||||
function evaluateTemplateForUI(template: string): string {
|
function evaluateTemplateForUI(template: string): string {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { relaunch } from '@tauri-apps/plugin-process'
|
import { relaunch } from '@tauri-apps/plugin-process'
|
||||||
import { check } from '@tauri-apps/plugin-updater'
|
import { check } from '@tauri-apps/plugin-updater'
|
||||||
|
import { h } from 'vue'
|
||||||
import { isLoggedIn, useAccount } from '@/api/account'
|
import { isLoggedIn, useAccount } from '@/api/account'
|
||||||
import { CN_HOST, isDev } from '@/data/constants'
|
import { CN_HOST, isDev } from '@/data/constants'
|
||||||
import { useWebFetcher } from '@/store/useWebFetcher'
|
import { useWebFetcher } from '@/store/useWebFetcher'
|
||||||
@@ -30,10 +31,14 @@ const accountInfo = useAccount()
|
|||||||
export const clientInited = ref(false)
|
export const clientInited = ref(false)
|
||||||
let tray: TrayIcon
|
let tray: TrayIcon
|
||||||
let heartbeatTimer: number | null = null
|
let heartbeatTimer: number | null = null
|
||||||
|
let updateCheckTimer: number | null = null
|
||||||
|
let updateNotificationRef: any = null
|
||||||
|
|
||||||
async function sendHeartbeat() {
|
async function sendHeartbeat() {
|
||||||
try {
|
try {
|
||||||
await invoke('heartbeat')
|
await invoke('heartbeat', undefined, {
|
||||||
|
headers: [['Origin', location.host]]
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送心跳失败:', 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) {
|
export async function initAll(isOnBoot: boolean) {
|
||||||
const setting = useSettings()
|
const setting = useSettings()
|
||||||
if (clientInited.value) {
|
if (clientInited.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
checkUpdate()
|
// 初始检查更新(不阻塞初始化)
|
||||||
|
if (!isDev) {
|
||||||
|
void checkUpdate()
|
||||||
|
}
|
||||||
const appWindow = getCurrentWindow()
|
const appWindow = getCurrentWindow()
|
||||||
let permissionGranted = await isPermissionGranted()
|
let permissionGranted = await isPermissionGranted()
|
||||||
|
|
||||||
@@ -86,7 +243,7 @@ export async function initAll(isOnBoot: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
initNotificationHandler()
|
initNotificationHandler()
|
||||||
const detach = await attachConsole()
|
await attachConsole()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const biliCookie = useBiliCookie()
|
const biliCookie = useBiliCookie()
|
||||||
await settings.init()
|
await settings.init()
|
||||||
@@ -135,13 +292,9 @@ export async function initAll(isOnBoot: boolean) {
|
|||||||
tooltip: 'VTsuru 事件收集器',
|
tooltip: 'VTsuru 事件收集器',
|
||||||
icon: iconData,
|
icon: iconData,
|
||||||
action: (event) => {
|
action: (event) => {
|
||||||
switch (event.type) {
|
if (event.type === 'DoubleClick' || event.type === 'Click') {
|
||||||
case 'DoubleClick':
|
|
||||||
appWindow.show()
|
appWindow.show()
|
||||||
break
|
appWindow.setFocus()
|
||||||
case 'Click':
|
|
||||||
appWindow.show()
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -179,7 +332,13 @@ export async function initAll(isOnBoot: boolean) {
|
|||||||
useAutoAction().init()
|
useAutoAction().init()
|
||||||
useBiliFunction().init()
|
useBiliFunction().init()
|
||||||
|
|
||||||
//startHeartbeat()
|
// startHeartbeat()
|
||||||
|
|
||||||
|
// 启动定期更新检查
|
||||||
|
if (!isDev) {
|
||||||
|
startUpdateCheck()
|
||||||
|
}
|
||||||
|
|
||||||
clientInited.value = true
|
clientInited.value = true
|
||||||
}
|
}
|
||||||
export function OnClientUnmounted() {
|
export function OnClientUnmounted() {
|
||||||
@@ -188,39 +347,14 @@ export function OnClientUnmounted() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
|
stopUpdateCheck()
|
||||||
tray.close()
|
tray.close()
|
||||||
// useDanmakuWindow().closeWindow();
|
// useDanmakuWindow().closeWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkUpdate() {
|
export async function checkUpdate() {
|
||||||
const update = await check()
|
// 手动检查更新(保留用于手动触发)
|
||||||
console.log(update)
|
await checkUpdatePeriodically()
|
||||||
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 const isInitedDanmakuClient = ref(false)
|
export const isInitedDanmakuClient = ref(false)
|
||||||
|
|||||||
6
src/components.d.ts
vendored
6
src/components.d.ts
vendored
@@ -19,6 +19,9 @@ declare module 'vue' {
|
|||||||
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||||
NAlert: typeof import('naive-ui')['NAlert']
|
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']
|
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
@@ -26,7 +29,10 @@ declare module 'vue' {
|
|||||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
NImage: typeof import('naive-ui')['NImage']
|
||||||
|
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
NTag: typeof import('naive-ui')['NTag']
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
NText: typeof import('naive-ui')['NText']
|
NText: typeof import('naive-ui')['NText']
|
||||||
NTime: typeof import('naive-ui')['NTime']
|
NTime: typeof import('naive-ui')['NTime']
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function InitVTsuru() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function InitOther() {
|
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 mod = await import('@hyperdx/browser')
|
||||||
const HyperDX = (mod as any).default ?? mod
|
const HyperDX = (mod as any).default ?? mod
|
||||||
HyperDX.init({
|
HyperDX.init({
|
||||||
@@ -59,7 +59,7 @@ async function InitOther() {
|
|||||||
advancedNetworkCapture: true, // Capture full HTTP request/response headers and bodies (default false)
|
advancedNetworkCapture: true, // Capture full HTTP request/response headers and bodies (default false)
|
||||||
ignoreUrls: [/localhost/i],
|
ignoreUrls: [/localhost/i],
|
||||||
})
|
})
|
||||||
// 将实例挂到窗口,便于后续设置全局属性(可选)
|
// 将实例挂到窗口,便于后续设置全局属性(可选)
|
||||||
;(window as any).__HyperDX__ = HyperDX
|
;(window as any).__HyperDX__ = HyperDX
|
||||||
}
|
}
|
||||||
// 加载其他数据
|
// 加载其他数据
|
||||||
|
|||||||
@@ -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
|
const isTauri = () => (window as any).__TAURI__ !== undefined || (window as any).__TAURI_INTERNAL__ !== undefined || '__TAURI__' in window
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
// 仅在 Tauri 环境下才动态加载相关初始化,避免把 @tauri-apps/* 打入入口
|
// 仅在 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
|
window.$mitt = emitter
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ export default {
|
|||||||
forceReload: true,
|
forceReload: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'read-danmaku',
|
||||||
|
name: 'client-read-danmaku',
|
||||||
|
component: async () => import('@/client/ClientReadDanmaku.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '读弹幕',
|
||||||
|
forceReload: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'danmaku-window',
|
path: 'danmaku-window',
|
||||||
name: 'client-danmaku-window-redirect',
|
name: 'client-danmaku-window-redirect',
|
||||||
|
|||||||
699
src/store/useSpeechService.ts
Normal file
699
src/store/useSpeechService.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -410,7 +410,7 @@ onMounted(async () => {
|
|||||||
background: cardBgMedium,
|
background: cardBgMedium,
|
||||||
border: borderSystem.medium,
|
border: borderSystem.medium,
|
||||||
borderRadius: borderRadius.large,
|
borderRadius: borderRadius.large,
|
||||||
boxShadow: shadowSystem.light,
|
boxShadow: 'none',
|
||||||
cursor: item.route ? 'pointer' : 'default',
|
cursor: item.route ? 'pointer' : 'default',
|
||||||
}" hoverable class="feature-card" @click="handleFunctionClick(item)">
|
}" hoverable class="feature-card" @click="handleFunctionClick(item)">
|
||||||
<NFlex vertical>
|
<NFlex vertical>
|
||||||
@@ -463,7 +463,7 @@ onMounted(async () => {
|
|||||||
background: cardBgMedium,
|
background: cardBgMedium,
|
||||||
border: borderSystem.light,
|
border: borderSystem.light,
|
||||||
borderRadius: borderRadius.large,
|
borderRadius: borderRadius.large,
|
||||||
boxShadow: shadowSystem.light,
|
boxShadow: 'none',
|
||||||
}" hoverable class="feature-card">
|
}" hoverable class="feature-card">
|
||||||
<NFlex vertical>
|
<NFlex vertical>
|
||||||
<NFlex align="center" style="margin-bottom: 10px;">
|
<NFlex align="center" style="margin-bottom: 10px;">
|
||||||
@@ -486,7 +486,7 @@ onMounted(async () => {
|
|||||||
background: cardBgMedium,
|
background: cardBgMedium,
|
||||||
border: borderSystem.light,
|
border: borderSystem.light,
|
||||||
borderRadius: borderRadius.large,
|
borderRadius: borderRadius.large,
|
||||||
boxShadow: shadowSystem.light,
|
boxShadow: 'none',
|
||||||
}" hoverable class="feature-card">
|
}" hoverable class="feature-card">
|
||||||
<NFlex vertical>
|
<NFlex vertical>
|
||||||
<NFlex align="center" style="margin-bottom: 10px;">
|
<NFlex align="center" style="margin-bottom: 10px;">
|
||||||
@@ -534,7 +534,7 @@ onMounted(async () => {
|
|||||||
width: '90vw',
|
width: '90vw',
|
||||||
maxWidth: '1400px',
|
maxWidth: '1400px',
|
||||||
borderRadius: borderRadius.xlarge,
|
borderRadius: borderRadius.xlarge,
|
||||||
boxShadow: shadowSystem.light,
|
boxShadow: 'none',
|
||||||
}">
|
}">
|
||||||
<NFlex vertical>
|
<NFlex vertical>
|
||||||
<NFlex justify="center" align="center" style="margin-bottom: 30px;">
|
<NFlex justify="center" align="center" style="margin-bottom: 30px;">
|
||||||
@@ -677,7 +677,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
transform: translateY(-4px);
|
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
|
&::before
|
||||||
left: 100%;
|
left: 100%;
|
||||||
@@ -700,14 +700,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
transform: translateY(-2px);
|
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
|
.entry-card
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
transform: translateY(-2px);
|
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
|
.streamer-avatar
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -738,7 +738,7 @@ onMounted(async () => {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover
|
&: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)
|
:deep(.n-button--small)
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -843,7 +843,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
transform: translateY(-3px);
|
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);
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ interface HistoryUpstatRecordModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GuardMemberModel {
|
interface GuardMemberModel {
|
||||||
guardUid: number
|
guardOUId: string
|
||||||
username: string
|
username: string
|
||||||
guardLevel: string
|
guardLevel: string
|
||||||
accompanyDays: number
|
accompanyDays: number
|
||||||
@@ -127,9 +127,12 @@ const guardPagination = computed(() => ({
|
|||||||
// 舰长列表表格列定义
|
// 舰长列表表格列定义
|
||||||
const guardColumns: DataTableColumns<GuardMemberModel> = [
|
const guardColumns: DataTableColumns<GuardMemberModel> = [
|
||||||
{
|
{
|
||||||
title: 'UID',
|
title: 'OUID',
|
||||||
key: 'guardUid',
|
key: 'guardOUId',
|
||||||
width: 100,
|
width: 250,
|
||||||
|
ellipsis: {
|
||||||
|
tooltip: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '用户名',
|
title: '用户名',
|
||||||
|
|||||||
@@ -316,10 +316,15 @@ function OnFileListChange(files: UploadFileInfo[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onUpdateClick(item: ResponsePointGoodModel) {
|
function onUpdateClick(item: ResponsePointGoodModel) {
|
||||||
|
const copiedItem = JSON.parse(JSON.stringify(item))
|
||||||
|
// 确保 setting 对象存在
|
||||||
|
if (!copiedItem.setting) {
|
||||||
|
copiedItem.setting = {
|
||||||
|
allowGuardLevel: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
currentGoodsModel.value = {
|
currentGoodsModel.value = {
|
||||||
goods: JSON.parse(JSON.stringify({
|
goods: copiedItem,
|
||||||
...item,
|
|
||||||
})),
|
|
||||||
fileList: item.cover
|
fileList: item.cover
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -949,6 +954,9 @@ onMounted(() => { })
|
|||||||
:checked="currentGoodsModel.goods.setting?.guardFree != undefined"
|
:checked="currentGoodsModel.goods.setting?.guardFree != undefined"
|
||||||
@update:checked="
|
@update:checked="
|
||||||
(v) => {
|
(v) => {
|
||||||
|
if (!currentGoodsModel.goods.setting) {
|
||||||
|
currentGoodsModel.goods.setting = { allowGuardLevel: 0 };
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
currentGoodsModel.goods.setting.guardFree = v ? { year: undefined, month: undefined } : undefined;
|
currentGoodsModel.goods.setting.guardFree = v ? { year: undefined, month: undefined } : undefined;
|
||||||
}
|
}
|
||||||
@@ -978,12 +986,22 @@ onMounted(() => { })
|
|||||||
:gap="8"
|
:gap="8"
|
||||||
>
|
>
|
||||||
<NSelect
|
<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"
|
:options="allowedYearOptions"
|
||||||
placeholder="请选择年份"
|
placeholder="请选择年份"
|
||||||
/>
|
/>
|
||||||
<NSelect
|
<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"
|
:options="allowedMonthOptions"
|
||||||
placeholder="请选择月份"
|
placeholder="请选择月份"
|
||||||
/>
|
/>
|
||||||
@@ -1009,7 +1027,15 @@ onMounted(() => { })
|
|||||||
</NTooltip>
|
</NTooltip>
|
||||||
</NText>
|
</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 :value="0">
|
||||||
不限
|
不限
|
||||||
</NRadioButton>
|
</NRadioButton>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1363
src/views/open_live/ReadDanmaku.vue.backup
Normal file
1363
src/views/open_live/ReadDanmaku.vue.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -553,6 +553,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NDivider v-if="goods.length > 0" />
|
||||||
<!-- 礼物列表区域 -->
|
<!-- 礼物列表区域 -->
|
||||||
<NSpin
|
<NSpin
|
||||||
:show="isLoading"
|
:show="isLoading"
|
||||||
@@ -651,6 +652,7 @@ onMounted(async () => {
|
|||||||
</NGi>
|
</NGi>
|
||||||
</NGrid>
|
</NGrid>
|
||||||
</NSpin>
|
</NSpin>
|
||||||
|
<NDivider v-if="goods.length > 0" />
|
||||||
|
|
||||||
<!-- 兑换确认模态框 -->
|
<!-- 兑换确认模态框 -->
|
||||||
<NModal
|
<NModal
|
||||||
|
|||||||
@@ -12,13 +12,20 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["node", "vue-vine/macros", "jszip"],
|
"types": ["node", "vue-vine/macros", "jszip"],
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"strict": true,
|
"strict": false,
|
||||||
|
"alwaysStrict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noImplicitThis": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"strictFunctionTypes": false,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"sourceMap": true,
|
"isolatedModules": true,
|
||||||
"isolatedModules": true
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"references": [{ "path": "./tsconfig.node.json" }],
|
"references": [{ "path": "./tsconfig.node.json" }],
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Reference in New Issue
Block a user